use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::time::Instant;
use regex::Regex;
use super::keymap::{Action, Motion, Overlay, Turn};
use super::lens::{Lens, Preset};
use super::moving::Moving;
use super::treemap::Maps;
use crate::delete::{Plan, Refused, Target};
use crate::rules::Kind;
use crate::size::{Size, human};
use crate::tree::{NodeId, Order, Sort, Tree};
use crate::walk::Hit;
const WHEEL: usize = 3;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Row {
pub id: NodeId,
pub depth: usize,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Mark {
None,
Partial,
All,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct Roll {
pub bytes: u64,
pub claims: usize,
pub unpriced: usize,
}
impl Roll {
#[must_use]
pub fn label(&self) -> String {
match (self.bytes, self.unpriced) {
(0, 1..) => Size::Unmeasured.label(),
(bytes, 1..) => format!("> {}", human(bytes)),
(bytes, 0) => human(bytes),
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Removing {
targets: HashMap<PathBuf, Live>,
total: usize,
done: usize,
planned: u64,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
struct Live {
planned: u64,
freed: u64,
swept: bool,
}
impl Removing {
fn new(targets: &[(PathBuf, u64)]) -> Self {
Self {
total: targets.len(),
done: 0,
planned: targets.iter().map(|(_, planned)| planned).sum(),
targets: targets
.iter()
.map(|(path, planned)| {
let live = Live {
planned: *planned,
..Live::default()
};
(path.clone(), live)
})
.collect(),
}
}
fn finished(&mut self, path: &Path) {
self.done = self.done.saturating_add(1).min(self.total);
if let Some(live) = self.targets.get_mut(path) {
live.swept = true;
}
}
fn freeing(&mut self, path: &Path, bytes: u64) {
if let Some(live) = self.targets.get_mut(path) {
live.freed = bytes;
}
}
#[must_use]
pub fn counted(&self) -> (usize, usize) {
(self.done, self.total)
}
#[must_use]
pub fn percent(&self) -> u8 {
percent(self.done, self.total)
}
#[must_use]
pub fn weighed(&self) -> Option<(u64, u64)> {
(self.planned > 0).then(|| (self.freed(), self.planned))
}
#[must_use]
pub fn freed(&self) -> u64 {
self.targets.values().map(|live| live.freed).sum()
}
#[must_use]
pub fn busiest(&self) -> Option<&Path> {
self.targets
.iter()
.filter(|(_, live)| !live.swept && live.freed > 0)
.max_by_key(|(path, live)| (live.planned, live.freed, *path))
.map(|(path, _)| path.as_path())
}
#[must_use]
pub fn label(&self) -> String {
let weight = match self.weighed() {
Some((freed, planned)) => format!(" · {} of {}", human(freed), human(planned)),
None => String::new(),
};
format!(
"removing {} of {} · {}%{weight}",
self.done,
plural(self.total, "directory", "directories"),
self.percent()
)
}
}
pub(super) fn percent(part: usize, whole: usize) -> u8 {
if whole == 0 {
return 0;
}
let scaled = part.saturating_mul(100) / whole;
u8::try_from(scaled.min(100)).unwrap_or(100)
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Planned {
pub requested: PathBuf,
pub resolved: PathBuf,
pub size: Size,
}
impl Planned {
#[must_use]
pub fn at(path: impl Into<PathBuf>, size: Size) -> Self {
let path = path.into();
Self {
requested: path.clone(),
resolved: path,
size,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Entry {
pub id: Option<NodeId>,
pub path: PathBuf,
pub target: Option<PathBuf>,
pub kind: Option<Kind>,
pub label: String,
pub size: Size,
pub hidden: bool,
pub kept: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Pending {
pub targets: Vec<PathBuf>,
pub entries: Vec<Entry>,
pub bytes: u64,
pub unpriced: usize,
pub view: String,
at: usize,
scroll: usize,
page: usize,
pub answer: Answer,
}
impl Pending {
#[must_use]
pub fn entries(&self) -> &[Entry] {
&self.entries
}
#[must_use]
pub fn at(&self) -> usize {
self.at
}
#[must_use]
pub fn scroll(&self) -> usize {
self.scroll
}
#[must_use]
pub fn page(&self) -> usize {
self.page
}
#[must_use]
pub fn hidden(&self) -> usize {
self.entries.iter().filter(|entry| entry.hidden).count()
}
#[must_use]
pub fn unrecoverable(&self) -> usize {
self.entries
.iter()
.filter(|entry| entry.kept.is_none() && entry.kind == Some(Kind::Unrecoverable))
.count()
}
#[must_use]
pub fn kept(&self) -> usize {
self.entries
.iter()
.filter(|entry| entry.kept.is_some())
.count()
}
fn current(&self) -> Option<&Entry> {
self.entries.get(self.at)
}
fn walk(&mut self, motion: Motion) {
let Some(last) = self.entries.len().checked_sub(1) else {
return;
};
let page = self.page.max(1);
self.at = match motion {
Motion::Up => self.at.saturating_sub(1),
Motion::Down => (self.at + 1).min(last),
Motion::PageUp => self.at.saturating_sub(page),
Motion::PageDown => (self.at + page).min(last),
Motion::Top => 0,
Motion::Bottom => last,
};
self.follow();
}
fn follow(&mut self) {
let page = self.page.max(1);
if self.at < self.scroll {
self.scroll = self.at;
} else if self.at >= self.scroll + page {
self.scroll = self.at + 1 - page;
}
self.scroll = self.scroll.min(self.entries.len().saturating_sub(1));
}
fn drop_at(&mut self, at: usize) -> Option<Entry> {
if at >= self.entries.len() {
return None;
}
let entry = self.entries.remove(at);
if entry.target.is_some() {
self.targets.retain(|path| path != &entry.path);
}
self.bytes = self.bytes.saturating_sub(entry.size.bytes().unwrap_or(0));
if entry.kept.is_none() && entry.size.bytes().is_none() {
self.unpriced = self.unpriced.saturating_sub(1);
}
self.at = self.at.min(self.entries.len().saturating_sub(1));
self.follow();
Some(entry)
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Answer {
#[default]
Cancel,
Delete,
}
impl Answer {
pub const ALL: [Self; 2] = [Self::Cancel, Self::Delete];
#[must_use]
pub fn label(self) -> &'static str {
match self {
Self::Cancel => "cancel",
Self::Delete => "delete",
}
}
#[must_use]
pub fn turn(self) -> Turn {
match self {
Self::Cancel => Turn::Prev,
Self::Delete => Turn::Next,
}
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Prompt {
chars: Vec<char>,
caret: usize,
error: Option<String>,
}
impl Prompt {
fn seeded(seed: &str) -> Self {
let chars: Vec<char> = seed.chars().collect();
Self {
caret: chars.len(),
chars,
error: None,
}
}
#[must_use]
pub fn text(&self) -> String {
self.chars.iter().collect()
}
#[must_use]
pub fn caret(&self) -> usize {
self.caret
}
#[must_use]
pub fn error(&self) -> Option<&str> {
self.error.as_deref()
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
struct Marked {
root: NodeId,
lens: Lens,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
struct Counts {
visible: Roll,
chosen: Roll,
all: Roll,
}
impl Counts {
fn absorb(&mut self, child: Self) {
add(&mut self.visible, child.visible);
add(&mut self.chosen, child.chosen);
add(&mut self.all, child.all);
}
}
fn add(into: &mut Roll, roll: Roll) {
into.bytes += roll.bytes;
into.claims += roll.claims;
into.unpriced += roll.unpriced;
}
const MARKED: u8 = 1;
const SPARED: u8 = 2;
enum Step {
Enter(NodeId, usize),
Leave(NodeId, usize),
}
fn tally(
tree: &Tree,
lens: &Lens,
marks: &[Marked],
spared: &HashSet<NodeId>,
moving: &Moving,
out: &mut Tallied,
) {
let Tallied {
counts,
selection,
map_stamps: stamps,
} = out;
counts.clear();
counts.resize(tree.minted(), Counts::default());
selection.clear();
let mut flags = vec![0u8; tree.minted()];
for mark in marks {
flags[mark.root] |= MARKED;
}
for &id in spared {
flags[id] |= SPARED;
}
let mut covering: Vec<(usize, &Lens)> = Vec::new();
let mut sparing: Vec<usize> = Vec::new();
let mut stack = vec![Step::Enter(tree.root(), 0)];
while let Some(step) = stack.pop() {
match step {
Step::Enter(id, depth) => {
if flags[id] & MARKED != 0 {
covering.extend(
marks
.iter()
.filter(|mark| mark.root == id)
.map(|mark| (depth, &mark.lens)),
);
}
if flags[id] & SPARED != 0 {
sparing.push(depth);
}
stack.push(Step::Leave(id, depth));
for &child in &tree.node(id).children {
stack.push(Step::Enter(child, depth + 1));
}
}
Step::Leave(id, depth) => {
let node = tree.node(id);
let mut here = Counts::default();
let mut beneath = 0u64;
if let Some(hit) = &node.hit {
let roll = Roll {
bytes: node.reclaimable,
claims: 1,
unpriced: node.unmeasured,
};
let seen = lens.matches(hit);
if seen {
here.visible = roll;
}
let deepest = sparing.last().copied();
let chosen = covering.iter().any(|&(at, mark)| {
deepest.is_none_or(|spared| at > spared) && mark.matches(hit)
});
if chosen && !moving.is_spent(id) {
let counted = Roll {
bytes: roll.bytes.saturating_sub(moving.freed_from(id)),
..roll
};
here.all = counted;
if seen {
here.chosen = counted;
}
if !moving.is_leaving(id) {
selection.push(id);
}
}
} else {
for &child in &node.children {
here.absorb(counts[child]);
if !stamps.is_empty() && counts[child].visible.claims > 0 {
beneath = beneath.wrapping_add(stamps[child]);
}
}
}
counts[id] = here;
if !stamps.is_empty() {
stamps[id] = stamp_of(id, here, beneath);
}
if flags[id] & MARKED != 0 {
covering.retain(|&(at, _)| at != depth);
}
if flags[id] & SPARED != 0 {
sparing.pop();
}
}
}
}
}
#[derive(Debug, Default)]
struct Tallied {
counts: Vec<Counts>,
selection: Vec<NodeId>,
map_stamps: Vec<u64>,
}
fn stamp_of(id: NodeId, counts: Counts, beneath: u64) -> u64 {
const SEED: u64 = 0xcbf2_9ce4_8422_2325;
const PRIME: u64 = 0x0000_0100_0000_01b3;
let mut stamp = SEED;
for value in [
id as u64,
counts.visible.bytes,
counts.visible.claims as u64,
counts.visible.unpriced as u64,
counts.chosen.claims as u64,
beneath,
] {
stamp = (stamp ^ value).wrapping_mul(PRIME);
}
stamp
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Effect {
None,
Quit,
Plan(Vec<Target>),
Delete(Vec<PathBuf>),
Price(Vec<PathBuf>),
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Notice {
said: String,
stands: bool,
}
impl Notice {
#[must_use]
pub fn passing(said: impl Into<String>) -> Self {
Self {
said: said.into(),
stands: false,
}
}
#[must_use]
pub fn standing(said: impl Into<String>) -> Self {
Self {
said: said.into(),
stands: true,
}
}
#[must_use]
pub fn said(&self) -> &str {
&self.said
}
#[must_use]
pub fn stands(&self) -> bool {
self.stands
}
}
#[derive(Debug)]
#[expect(
clippy::struct_excessive_bools,
reason = "five independent facts about one view — is the walk running, is a removal \
running, do the rows still describe the tree, are the levels in order, was the \
cursor taken away. The lint's advice is a state machine, and these do not form \
one: every combination of them happens."
)]
pub struct View {
tree: Tree,
sort: Sort,
expanded: HashSet<NodeId>,
marks: Vec<Marked>,
mark_stamp: u64,
spared: HashSet<NodeId>,
counts: Vec<Counts>,
selection: Vec<NodeId>,
map_stamps: Vec<u64>,
rows: Vec<Row>,
cursor: Option<usize>,
deselected: bool,
scroll: usize,
page: usize,
lens: Lens,
prompt: Option<Prompt>,
help: Option<usize>,
pending: Option<Pending>,
pricing: HashSet<PathBuf>,
scanning: bool,
removing: Option<Removing>,
quitting: bool,
notice: Option<Notice>,
stale: bool,
sorted: bool,
moving: Moving,
drained: HashMap<NodeId, Roll>,
now: Instant,
opened: Instant,
seen: usize,
kept: HashMap<NodeId, String>,
freed: u64,
map: Map,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
struct Map {
possible: Maps,
on: bool,
}
impl View {
#[must_use]
pub fn new(tree: Tree) -> Self {
let opened = Instant::now();
let mut view = Self {
expanded: HashSet::from([tree.root()]),
seen: tree.minted(),
tree,
sort: Sort::default(),
marks: Vec::new(),
mark_stamp: 0,
spared: HashSet::new(),
counts: Vec::new(),
selection: Vec::new(),
map_stamps: Vec::new(),
rows: Vec::new(),
cursor: None,
deselected: false,
scroll: 0,
page: 20,
lens: Lens::default(),
prompt: None,
help: None,
pending: None,
pricing: HashSet::new(),
scanning: true,
removing: None,
quitting: false,
notice: None,
stale: true,
sorted: false,
moving: Moving::new(opened),
drained: HashMap::new(),
now: opened,
opened,
kept: HashMap::new(),
freed: 0,
map: Map {
possible: Maps::Unread,
on: true,
},
};
view.sync();
view
}
#[must_use]
pub fn showing_files(mut self) -> Self {
self.lens = self.lens.with_files(true);
self.stale = true;
self.sync();
self
}
pub fn found(&mut self, hit: Hit) {
self.tree.insert(hit);
self.stale = true;
self.sorted = false;
}
pub fn pricing(&mut self, path: &Path) {
if let Some(id) = self.tree.find(path) {
self.moving.heats(id);
}
}
pub fn priced(&mut self, path: &Path, size: Size) {
if let Some(id) = self.tree.find(path) {
self.moving.cools(id);
}
self.tree.price(path, size);
self.stale = true;
self.sorted = false;
}
pub fn freeing(&mut self, path: &Path, bytes: u64) {
if let Some(removing) = &mut self.removing {
removing.freeing(path, bytes);
}
if let Some(id) = self.tree.find(path) {
self.moving.frees(id, bytes);
self.stale = true;
}
}
pub fn removed(&mut self, path: &Path, bytes: u64, complete: bool) {
if let Some(removing) = &mut self.removing {
removing.freeing(path, bytes);
}
let Some(id) = self.tree.find(path) else {
return;
};
if complete {
self.moving.spends(id, bytes, self.now);
} else {
self.moving.frees(id, bytes);
}
self.stale = true;
}
pub fn swept(&mut self, path: &Path) {
if let Some(removing) = &mut self.removing {
removing.finished(path);
}
}
pub fn refused(&mut self, kept: &[Refused]) {
for refused in kept {
if let Some(id) = self.tree.find(&refused.path) {
self.kept.insert(id, refused.reason.to_string());
}
}
}
pub fn scanned(&mut self) {
self.scanning = false;
self.moving.cooled();
}
pub fn repriced(&mut self, claims: &[PathBuf], notice: Notice) {
for claim in claims {
self.pricing.remove(claim);
}
self.notice = Some(notice);
}
pub fn deleted(&mut self, notice: Notice, freed: u64) {
for (id, bytes) in self.moving.leaving().collect::<Vec<_>>() {
if self.moving.is_spent(id) {
continue;
}
let path = self.tree.node(id).path.clone();
self.tree.shrink(&path, bytes);
}
self.removing = None;
self.notice = Some(notice);
self.freed = freed;
self.moving.banked();
self.stale = true;
}
pub fn ask(&mut self, plan: &Plan) {
let targets: Vec<Planned> = plan
.targets()
.iter()
.map(|target| Planned {
requested: target.requested.clone(),
resolved: target.path.clone(),
size: target.size,
})
.collect();
self.asking(&targets, plan.kept());
}
pub fn asking(&mut self, targets: &[Planned], kept: &[Refused]) {
if targets.is_empty() {
if kept.is_empty() {
self.says("nothing to delete");
} else {
self.warns(format!(
"nothing to delete: {} left alone by the safety model",
kept.len()
));
}
return;
}
let mut entries: Vec<Entry> = targets
.iter()
.map(|target| {
self.entry(
&target.requested,
Some(target.resolved.clone()),
target.size,
None,
)
})
.chain(kept.iter().map(|refused| {
self.entry(
&refused.path,
None,
Size::Unmeasured,
Some(refused.reason.to_string()),
)
}))
.collect();
entries.sort_by(|a, b| {
kind_order(a.kind)
.cmp(&kind_order(b.kind))
.then_with(|| a.path.cmp(&b.path))
});
let priced = |entry: &&Entry| entry.kept.is_none();
let bytes = entries
.iter()
.filter(priced)
.filter_map(|entry| entry.size.bytes())
.sum();
let unpriced = entries
.iter()
.filter(priced)
.filter(|entry| entry.size.bytes().is_none())
.count();
self.pending = Some(Pending {
targets: targets
.iter()
.map(|target| target.requested.clone())
.collect(),
bytes,
unpriced,
entries,
view: self.lens.describe(),
at: 0,
scroll: 0,
page: 8,
answer: Answer::Cancel,
});
}
fn entry(
&self,
path: &Path,
target: Option<PathBuf>,
size: Size,
kept: Option<String>,
) -> Entry {
let id = self.tree.find(path);
let hit = id.and_then(|id| self.tree.node(id).hit.as_ref());
Entry {
id,
path: path.to_path_buf(),
target,
kind: hit.and_then(Hit::kind),
label: hit.map_or_else(
|| crate::walk::UNLABELLED.to_owned(),
|hit| hit.label().into_owned(),
),
size: match size.bytes() {
Some(_) => size,
None => hit.map_or(Size::Unmeasured, |hit| hit.size),
},
hidden: hit.is_some_and(|hit| !self.lens.matches(hit)),
kept,
}
}
pub fn sync(&mut self) {
if !self.stale {
return;
}
let anchor = self.anchor();
if !self.sorted {
self.tree.sort_by(self.sort);
self.sorted = true;
}
for id in self.seen..self.tree.minted() {
self.moving.arrived(id, self.now);
}
self.seen = self.tree.minted();
let (tree, moving) = (&self.tree, &self.moving);
let held = (self.marks.len(), self.spared.len());
self.marks
.retain(|mark| tree.is_attached(mark.root) && !moving.is_leaving(mark.root));
self.spared.retain(|&id| tree.is_attached(id));
if (self.marks.len(), self.spared.len()) != held {
self.mark_stamp += 1;
}
self.expanded.retain(|&id| self.tree.is_attached(id));
self.kept.retain(|&id, _| self.tree.is_attached(id));
for id in self
.moving
.hot()
.filter(|&id| !self.tree.is_attached(id))
.collect::<Vec<_>>()
{
self.moving.cools(id);
}
self.recount();
self.reflatten();
self.settle(&anchor);
self.follow_cursor();
self.stale = false;
}
pub fn animate(&mut self, now: Instant) {
self.now = now;
self.moving.tick(now);
for id in self.moving.collapsed(now) {
let path = self.tree.node(id).path.clone();
self.tree.remove(&path);
self.stale = true;
}
self.sync();
self.recount_drains();
let targets: Vec<(NodeId, u64, bool)> = std::iter::once(self.tree.root())
.chain(
self.rows
.iter()
.skip(self.scroll)
.take(self.page)
.map(|row| row.id),
)
.map(|id| (id, self.live(id).bytes, self.drained.contains_key(&id)))
.collect();
self.moving.advance(now, &targets, self.freed_total());
}
fn freed_total(&self) -> u64 {
self.freed + self.moving.freed_so_far()
}
fn recount_drains(&mut self) {
self.drained.clear();
for (id, freed) in self.moving.leaving().collect::<Vec<_>>() {
let roll = self.roll(id);
let gone = if self.moving.is_spent(id) {
roll
} else {
Roll {
bytes: freed.min(roll.bytes),
claims: 0,
unpriced: 0,
}
};
let mut at = Some(id);
while let Some(current) = at {
let drained = self.drained.entry(current).or_default();
drained.bytes += gone.bytes;
drained.claims += gone.claims;
drained.unpriced += gone.unpriced;
at = self.tree.node(current).parent;
}
}
}
fn live(&self, id: NodeId) -> Roll {
let roll = self.roll(id);
let Some(gone) = self.drained.get(&id) else {
return roll;
};
Roll {
bytes: roll.bytes.saturating_sub(gone.bytes),
claims: roll.claims.saturating_sub(gone.claims),
unpriced: roll.unpriced.saturating_sub(gone.unpriced),
}
}
#[must_use]
pub fn is_moving(&self) -> bool {
self.moving.is_moving()
}
pub fn allow_maps(&mut self, possible: Maps) {
if self.map.possible == possible {
return;
}
if let (true, Some(why)) = (self.maps(), possible.why()) {
self.says(why);
}
self.map.possible = possible;
self.stale = true;
}
#[must_use]
pub fn maps(&self) -> bool {
self.map.possible.can() && self.map.on
}
pub fn viewport(&mut self, page: usize) {
self.page = page.max(1);
self.follow_cursor();
}
#[must_use]
pub fn rows(&self) -> &[Row] {
&self.rows
}
#[must_use]
pub fn cursor(&self) -> Option<usize> {
self.cursor
}
#[must_use]
pub fn scroll(&self) -> usize {
self.scroll
}
#[must_use]
pub fn tree(&self) -> &Tree {
&self.tree
}
#[must_use]
pub fn is_expanded(&self, id: NodeId) -> bool {
self.expanded.contains(&id)
}
#[must_use]
pub fn roll(&self, id: NodeId) -> Roll {
if self.is_sifted() {
return self
.counts
.get(id)
.map_or_else(Roll::default, |counts| counts.visible);
}
let node = self.tree.node(id);
Roll {
bytes: node.reclaimable,
claims: node.claims,
unpriced: node.unmeasured,
}
}
#[must_use]
pub fn drawn(&self, id: NodeId) -> Roll {
Roll {
bytes: self.moving.shown(id, self.live(id).bytes),
..self.live(id)
}
}
#[must_use]
pub fn drawn_total(&self) -> Roll {
self.drawn(self.tree.root())
}
#[must_use]
pub fn drawn_freed(&self) -> u64 {
self.moving.freed()
}
#[must_use]
pub fn has_freed(&self) -> bool {
self.freed_total() > 0
}
#[must_use]
pub fn freshness(&self, id: NodeId) -> f64 {
self.moving.freshness(id)
}
#[must_use]
pub fn is_pricing(&self, id: NodeId) -> bool {
self.moving.is_hot(id)
}
#[must_use]
pub fn is_freeing(&self, id: NodeId) -> bool {
self.moving.is_freeing(id)
}
#[must_use]
pub fn is_spent(&self, id: NodeId) -> bool {
self.moving.is_spent(id)
}
fn is_leaving(&self, id: NodeId) -> bool {
self.moving.is_freeing(id) || self.moving.is_spent(id)
}
#[must_use]
pub fn is_cascading(&self, id: NodeId) -> bool {
self.moving.is_cascading(id)
}
#[must_use]
pub fn shimmer(&self, width: usize) -> usize {
self.moving.shimmer(width, self.opened)
}
#[must_use]
pub fn kept_reason(&self, id: NodeId) -> Option<&str> {
self.kept.get(&id).map(String::as_str)
}
#[must_use]
pub fn mark_of(&self, id: NodeId) -> Mark {
let Some(counts) = self.counts.get(id) else {
return Mark::None;
};
let whole = self.roll(id).claims;
if counts.chosen.claims == 0 || whole == 0 {
Mark::None
} else if counts.chosen.claims >= whole {
Mark::All
} else {
Mark::Partial
}
}
#[must_use]
pub fn map_stamp(&self, id: NodeId) -> u64 {
self.map_stamps
.get(id)
.copied()
.unwrap_or_else(|| self.tree.stamp(id))
}
#[must_use]
pub fn mark_stamp(&self) -> u64 {
self.mark_stamp
}
#[must_use]
pub fn share(&self, id: NodeId) -> f64 {
if self.mark_of(id) == Mark::All {
return 1.0;
}
let whole = self.roll(id);
let marked = self
.counts
.get(id)
.map_or_else(Roll::default, |counts| counts.chosen);
#[expect(
clippy::cast_precision_loss,
reason = "a ratio bound for one of seven block glyphs has no precision to lose"
)]
let share = match (whole.unpriced, whole.bytes, whole.claims) {
(0, bytes @ 1.., _) => marked.bytes as f64 / bytes as f64,
(_, _, claims @ 1..) => marked.claims as f64 / claims as f64,
_ => 0.0,
};
share.clamp(0.0, 1.0)
}
#[must_use]
pub fn marked(&self) -> Roll {
self.counts
.get(self.tree.root())
.map_or_else(Roll::default, |counts| counts.all)
}
#[must_use]
pub fn hidden(&self) -> usize {
self.counts
.get(self.tree.root())
.map_or(0, |counts| counts.all.claims - counts.chosen.claims)
}
#[must_use]
pub fn total(&self) -> Roll {
self.roll(self.tree.root())
}
#[must_use]
pub fn out_of_view(&self) -> usize {
self.tree
.node(self.tree.root())
.claims
.saturating_sub(self.total().claims)
}
#[must_use]
pub fn filter(&self) -> Option<&str> {
self.lens.pattern()
}
#[must_use]
pub fn preset(&self) -> Option<Preset> {
self.lens.preset()
}
#[must_use]
pub fn view_label(&self) -> String {
self.preset().map_or_else(
|| self.lens.axes_label(),
|preset| preset.label().to_owned(),
)
}
#[must_use]
pub fn lens(&self) -> &Lens {
&self.lens
}
#[must_use]
pub fn prompt(&self) -> Option<&Prompt> {
self.prompt.as_ref()
}
#[must_use]
pub fn help(&self) -> Option<usize> {
self.help
}
pub fn clamp_help(&mut self, furthest: usize) {
if let Some(at) = self.help {
self.help = Some(at.min(furthest));
}
}
#[must_use]
pub fn pending(&self) -> Option<&Pending> {
self.pending.as_ref()
}
pub fn listing(&mut self, page: usize) {
if let Some(pending) = &mut self.pending {
pending.page = page.max(1);
pending.follow();
}
}
#[must_use]
pub fn is_scanning(&self) -> bool {
self.scanning
}
#[must_use]
pub fn is_deleting(&self) -> bool {
self.removing.is_some()
}
#[must_use]
pub fn removing(&self) -> Option<&Removing> {
self.removing.as_ref()
}
#[cfg(test)]
pub(crate) fn deleting_for_test(&mut self) {
self.removing = Some(Removing::new(&[(PathBuf::from("/scan/target"), 0)]));
}
#[must_use]
pub fn notice(&self) -> Option<&str> {
self.notice.as_ref().map(|notice| notice.said.as_str())
}
#[must_use]
pub fn notice_stands(&self) -> bool {
self.notice.as_ref().is_some_and(|notice| notice.stands)
}
fn says(&mut self, said: impl Into<String>) {
self.notice = Some(Notice::passing(said));
}
fn warns(&mut self, said: impl Into<String>) {
self.notice = Some(Notice::standing(said));
}
fn expire(&mut self) {
if !self.notice_stands() {
self.notice = None;
}
}
#[must_use]
pub fn sort(&self) -> Sort {
self.sort
}
#[must_use]
pub fn overlay(&self) -> Option<Overlay> {
if self.prompt.is_some() {
Some(Overlay::Prompt)
} else if self.help.is_some() {
Some(Overlay::Help)
} else if self.pending.is_some() {
Some(Overlay::Confirm)
} else {
None
}
}
pub fn apply(&mut self, action: Action) -> Effect {
self.sync();
if !matches!(action, Action::Ignore | Action::Back) {
self.expire();
}
match action {
Action::Quit => return self.quit(),
Action::Ignore => {}
Action::Help => {
self.help = if self.help.is_some() { None } else { Some(0) };
}
Action::Back => self.step_back(),
Action::Dismiss => self.notice = None,
Action::OpenFilter => {
self.prompt = Some(Prompt::seeded(self.filter().unwrap_or_default()));
}
Action::Type(character) => self.edit(|prompt| {
prompt.chars.insert(prompt.caret, character);
prompt.caret += 1;
}),
Action::Erase => self.edit(|prompt| {
if prompt.caret > 0 {
prompt.caret -= 1;
prompt.chars.remove(prompt.caret);
}
}),
Action::EraseAhead => self.edit(|prompt| {
if prompt.caret < prompt.chars.len() {
prompt.chars.remove(prompt.caret);
}
}),
Action::Wipe => self.edit(|prompt| {
prompt.chars.clear();
prompt.caret = 0;
}),
Action::Caret(motion) => self.edit(|prompt| {
prompt.caret = match motion {
Motion::Up | Motion::PageUp => prompt.caret.saturating_sub(1),
Motion::Down | Motion::PageDown => (prompt.caret + 1).min(prompt.chars.len()),
Motion::Top => 0,
Motion::Bottom => prompt.chars.len(),
};
}),
Action::Submit => self.submit(),
Action::Scroll(motion) => self.scroll_help(motion),
Action::Highlight(turn) => {
if let Some(pending) = &mut self.pending {
pending.answer = match turn {
Turn::Prev => Answer::Cancel,
Turn::Next => Answer::Delete,
};
}
}
Action::Answer => return self.answer(),
Action::Listing(motion) => {
if let Some(pending) = &mut self.pending {
pending.walk(motion);
}
}
Action::Spare => self.spare_entry(),
Action::CyclePreset(turn) => self.cycle_preset(turn),
Action::CycleTiers => self.cycle_tiers(),
Action::ToggleFiles => self.toggle_files(),
Action::ToggleKind(kind) => self.toggle_kind(kind),
Action::Cursor(motion) => self.move_cursor(motion),
Action::ScrollRows(motion) => self.scroll_rows(motion),
Action::Expand => self.expand(),
Action::Collapse => self.collapse(),
Action::ToggleSubtree => self.toggle_subtree(),
Action::CollapseAll => {
self.expanded.retain(|&id| id == self.tree.root());
self.stale = true;
}
Action::Mark => self.toggle_mark(),
Action::MarkAll => self.mark_all(),
Action::Commit => return self.commit(),
Action::ToggleMap => self.toggle_map(),
Action::CycleSort => self.resort(Sort {
by: self.sort.by.next(),
reverse: self.sort.reverse,
}),
Action::ReverseSort => self.resort(Sort {
reverse: !self.sort.reverse,
..self.sort
}),
Action::SortBy(order) => self.sort_by(order),
Action::Select(id) => {
self.point_at(id);
}
Action::OpenRow(id) => self.open_row(id),
Action::MarkRow(id) => self.mark_row(id),
Action::Price(id) => return self.price_row(id),
}
self.sync();
Effect::None
}
fn toggle_map(&mut self) {
if let Some(why) = self.map.possible.why() {
self.says(why);
return;
}
self.map.on = !self.map.on;
self.stale = true;
}
fn quit(&mut self) -> Effect {
if self.is_deleting() {
self.quitting = true;
self.says("the removal has to finish — closing the moment it does");
return Effect::None;
}
Effect::Quit
}
#[must_use]
pub fn wants_to_quit(&self) -> bool {
self.quitting && !self.is_deleting()
}
fn step_back(&mut self) {
if self.prompt.take().is_some() {
return;
}
if self.help.take().is_some() {
return;
}
if self.pending.take().is_some() {
return;
}
if self.notice.take().is_some() {
return;
}
if self.lens.pattern().is_some() {
self.lens = self.lens.clone().matching(None);
self.stale = true;
return;
}
if self.lens != Lens::default() {
self.lens = Lens::default();
self.stale = true;
return;
}
if !self.marks.is_empty() {
self.clear_marks();
}
}
fn edit(&mut self, change: impl FnOnce(&mut Prompt)) {
if let Some(prompt) = &mut self.prompt {
change(prompt);
prompt.error = None;
}
}
fn submit(&mut self) {
let Some(prompt) = &mut self.prompt else {
return;
};
let pattern = prompt.text();
if pattern.is_empty() {
self.prompt = None;
self.lens = self.lens.clone().matching(None);
self.stale = true;
return;
}
match Regex::new(&pattern) {
Ok(regex) => {
self.lens = self.lens.clone().matching(Some(regex));
self.prompt = None;
self.stale = true;
}
Err(err) => {
let reason = err.to_string();
prompt.error = Some(reason.lines().last().unwrap_or("not a regex").to_owned());
}
}
}
fn cycle_preset(&mut self, turn: Turn) {
let next = match self.preset() {
Some(at) => match turn {
Turn::Next => at.next(),
Turn::Prev => at.prev(),
},
None => Preset::default(),
};
self.lens = Lens::showing(next).matching(self.held_pattern());
self.narrowed(format!("showing {}", next.what()));
}
fn cycle_tiers(&mut self) {
let tiers = self.lens.tiers().next();
self.lens = self.lens.clone().with_tiers(tiers);
self.off_the_presets();
}
fn toggle_files(&mut self) {
let files = !self.lens.files();
self.lens = self.lens.clone().with_files(files);
self.off_the_presets();
}
fn toggle_kind(&mut self, kind: Kind) {
let kinds = self.lens.kinds().toggling(kind);
self.lens = self.lens.clone().with_kinds(kinds);
self.off_the_presets();
}
fn off_the_presets(&mut self) {
let said = self.lens.axes_label();
self.narrowed(format!("showing {said}"));
}
fn held_pattern(&self) -> Option<Regex> {
self.lens.pattern().and_then(|held| Regex::new(held).ok())
}
fn narrowed(&mut self, said: String) {
self.stale = true;
self.sync();
let hidden = self.hidden();
self.says(if hidden == 0 {
said
} else {
format!(
"{said} · {} still marked and out of sight",
plural(hidden, "directory", "directories")
)
});
}
fn scroll_help(&mut self, motion: Motion) {
let Some(at) = self.help else {
return;
};
let page = self.page;
self.help = Some(match motion {
Motion::Up => at.saturating_sub(1),
Motion::Down => at + 1,
Motion::PageUp => at.saturating_sub(page),
Motion::PageDown => at + page,
Motion::Top => 0,
Motion::Bottom => usize::MAX,
});
}
fn answer(&mut self) -> Effect {
let Some(pending) = self.pending.take() else {
return Effect::None;
};
match pending.answer {
Answer::Cancel => Effect::None,
Answer::Delete => {
let weighed: Vec<(PathBuf, u64)> = pending
.entries
.iter()
.filter(|entry| entry.target.is_some())
.map(|entry| (entry.path.clone(), entry.size.bytes().unwrap_or(0)))
.collect();
self.removing = Some(Removing::new(&weighed));
self.notice = None;
Effect::Delete(pending.targets)
}
}
}
fn spare_entry(&mut self) {
let Some(pending) = &self.pending else {
return;
};
let at = pending.at;
let Some(entry) = pending.current().cloned() else {
return;
};
if let Some(pending) = &mut self.pending {
pending.drop_at(at);
}
if let Some(id) = entry.id {
self.unmark(id);
}
self.stale = true;
self.sync();
let left = self
.pending
.as_ref()
.map_or(0, |pending| pending.entries.len());
if left == 0 {
self.pending = None;
self.says("nothing left in the batch");
return;
}
self.says(format!("{} unmarked", entry.path.display()));
}
fn commit(&mut self) -> Effect {
if self.is_deleting() {
self.says("a removal is already running");
return Effect::None;
}
let batch = self.batch();
if batch.is_empty() {
self.says("nothing is marked — space marks a row's whole subtree");
return Effect::None;
}
Effect::Plan(batch)
}
#[must_use]
pub fn batch(&self) -> Vec<Target> {
let mut batch: Vec<Target> = self
.selection
.iter()
.filter_map(|&id| self.tree.node(id).hit.as_ref().map(Target::from))
.collect();
batch.sort_by(|a, b| a.path.cmp(&b.path));
batch
}
fn resort(&mut self, sort: Sort) {
self.sort = sort;
self.sorted = false;
self.stale = true;
}
fn sort_by(&mut self, order: Order) {
self.resort(if self.sort.by == order {
Sort {
by: order,
reverse: !self.sort.reverse,
}
} else {
Sort::by(order)
});
}
fn point_at(&mut self, id: NodeId) -> bool {
let Some(at) = self.rows.iter().position(|row| row.id == id) else {
return false;
};
self.cursor = Some(at);
self.deselected = false;
self.follow_cursor();
true
}
fn open_row(&mut self, id: NodeId) {
if !self.point_at(id) || self.tree.children(id).is_empty() {
return;
}
if !self.expanded.insert(id) {
self.expanded.remove(&id);
}
self.stale = true;
}
fn mark_row(&mut self, id: NodeId) {
if self.point_at(id) {
self.mark_at(id);
}
}
fn price_row(&mut self, id: NodeId) -> Effect {
if !self.point_at(id) {
return Effect::None;
}
let (waiting, running): (Vec<PathBuf>, Vec<PathBuf>) = self
.unpriced_under(id)
.into_iter()
.partition(|path| !self.pricing.contains(path));
if waiting.is_empty() {
if running.is_empty() {
self.says("everything under here already carries a price");
} else {
self.says(format!(
"{} under here is already being priced",
plural(running.len(), "directory", "directories")
));
}
return Effect::None;
}
self.pricing.extend(waiting.iter().cloned());
self.says(format!(
"pricing {}…",
plural(waiting.len(), "directory", "directories")
));
Effect::Price(waiting)
}
fn unpriced_under(&self, id: NodeId) -> Vec<PathBuf> {
let mut found = Vec::new();
let mut stack = vec![id];
while let Some(id) = stack.pop() {
if !self.shown(id) {
continue;
}
let node = self.tree.node(id);
match &node.hit {
Some(hit) if hit.size.bytes().is_none() => found.push(hit.path.clone()),
Some(_) => {}
None => stack.extend(node.children.iter().copied()),
}
}
found.sort();
found
}
fn scroll_rows(&mut self, motion: Motion) {
if self.rows.is_empty() {
return;
}
let last = self.rows.len() - 1;
let furthest = self.rows.len().saturating_sub(self.page);
self.scroll = match motion {
Motion::Up => self.scroll.saturating_sub(WHEEL),
Motion::Down => (self.scroll + WHEEL).min(furthest),
Motion::PageUp => self.scroll.saturating_sub(self.page),
Motion::PageDown => (self.scroll + self.page).min(furthest),
Motion::Top => 0,
Motion::Bottom => furthest,
};
if let Some(at) = self.cursor {
self.cursor = Some(at.clamp(self.scroll, (self.scroll + self.page - 1).min(last)));
}
}
fn move_cursor(&mut self, motion: Motion) {
if self.rows.is_empty() {
return;
}
let last = self.rows.len() - 1;
let at = match self.cursor {
None => match motion {
Motion::Up | Motion::PageUp | Motion::Bottom => last,
Motion::Down | Motion::PageDown | Motion::Top => 0,
},
Some(at) => match motion {
Motion::Up => at.saturating_sub(1),
Motion::Down => (at + 1).min(last),
Motion::PageUp => at.saturating_sub(self.page),
Motion::PageDown => (at + self.page).min(last),
Motion::Top => 0,
Motion::Bottom => last,
},
};
self.cursor = Some(at);
self.deselected = false;
self.follow_cursor();
}
#[must_use]
pub fn row(&self) -> Option<Row> {
self.cursor.and_then(|at| self.rows.get(at).copied())
}
fn anchor(&self) -> Vec<PathBuf> {
let mut chain = Vec::new();
let Some(row) = self.row() else {
return chain;
};
let mut at = Some(row.id);
while let Some(id) = at {
let node = self.tree.node(id);
chain.push(node.path.clone());
at = node.parent;
}
chain
}
fn settle(&mut self, chain: &[PathBuf]) {
self.cursor = chain.iter().find_map(|path| {
self.rows
.iter()
.position(|row| self.tree.node(row.id).path == *path)
});
if self.cursor.is_none() {
if chain.is_empty() && !self.deselected && !self.rows.is_empty() {
self.cursor = Some(0);
} else if !chain.is_empty() {
self.deselected = true;
}
}
}
fn follow_cursor(&mut self) {
if let Some(at) = self.cursor {
if at < self.scroll {
self.scroll = at;
} else if at >= self.scroll + self.page {
self.scroll = at + 1 - self.page;
}
}
self.scroll = self.scroll.min(self.rows.len().saturating_sub(1));
}
fn expand(&mut self) {
let Some(row) = self.row() else {
return;
};
if self.tree.children(row.id).is_empty() {
return;
}
if self.expanded.insert(row.id) {
self.stale = true;
} else if self.cursor.is_some_and(|at| at + 1 < self.rows.len()) {
self.move_cursor(Motion::Down);
}
}
fn collapse(&mut self) {
let Some(row) = self.row() else {
return;
};
if self.expanded.remove(&row.id) {
self.stale = true;
} else if let Some(parent) = self.tree.node(row.id).parent {
let path = self.tree.node(parent).path.clone();
if let Some(at) = self
.rows
.iter()
.position(|row| self.tree.node(row.id).path == path)
{
self.cursor = Some(at);
self.deselected = false;
self.follow_cursor();
}
}
}
fn toggle_subtree(&mut self) {
let Some(row) = self.row() else {
return;
};
let opening = !self.expanded.contains(&row.id);
let mut stack = vec![row.id];
while let Some(id) = stack.pop() {
if self.tree.children(id).is_empty() {
continue;
}
if opening {
self.expanded.insert(id);
} else {
self.expanded.remove(&id);
}
stack.extend(self.tree.children(id).iter().copied());
}
self.stale = true;
}
fn reflatten(&mut self) {
self.rows.clear();
let mut stack = vec![(self.tree.root(), 0usize)];
while let Some((id, depth)) = stack.pop() {
if !self.shown(id) {
continue;
}
self.rows.push(Row { id, depth });
if self.expanded.contains(&id) {
for &child in self.tree.children(id).iter().rev() {
stack.push((child, depth + 1));
}
}
}
}
fn shown(&self, id: NodeId) -> bool {
if !self.is_sifted() {
return true;
}
if id == self.tree.root() && self.tree.node(id).claims == 0 {
return true;
}
self.roll(id).claims > 0
}
fn toggle_mark(&mut self) {
if let Some(row) = self.row() {
self.mark_at(row.id);
}
}
fn mark_at(&mut self, id: NodeId) {
if self.is_leaving(id) {
return;
}
if self.mark_of(id) == Mark::All {
self.unmark(id);
} else {
let chain = self.ancestry(id);
self.moving.cascade(&chain, self.now);
self.mark(id);
}
}
fn ancestry(&self, id: NodeId) -> Vec<NodeId> {
let mut chain = Vec::new();
let mut at = Some(id);
while let Some(current) = at {
chain.push(current);
at = self.tree.node(current).parent;
}
chain
}
fn mark_all(&mut self) {
if self.marks.is_empty() {
self.mark(self.tree.root());
} else {
self.clear_marks();
}
}
fn mark(&mut self, id: NodeId) {
self.mark_stamp += 1;
let lens = self.lens.clone();
let inside: Vec<NodeId> = self
.spared
.iter()
.copied()
.filter(|&spared| self.descends_from(spared, id))
.collect();
for spared in inside {
self.spared.remove(&spared);
}
let tree = &self.tree;
self.marks.retain(|mark| {
!(mark.lens == lens && mark.root != id && descends_from(tree, mark.root, id))
});
if !self
.marks
.iter()
.any(|mark| mark.root == id && mark.lens == lens)
{
self.marks.push(Marked { root: id, lens });
}
self.stale = true;
}
fn unmark(&mut self, id: NodeId) {
self.mark_stamp += 1;
self.marks.retain(|mark| mark.root != id);
self.stale = true;
self.sync();
if self.mark_of(id) == Mark::None && !self.selects_anything_under(id) {
return;
}
let tree = &self.tree;
self.marks
.retain(|mark| !descends_from(tree, mark.root, id));
self.spared.insert(id);
self.stale = true;
}
fn selects_anything_under(&self, id: NodeId) -> bool {
self.counts
.get(id)
.is_some_and(|counts| counts.all.claims > 0)
}
fn clear_marks(&mut self) {
if !self.marks.is_empty() || !self.spared.is_empty() {
self.mark_stamp += 1;
}
self.marks.clear();
self.spared.clear();
self.counts.clear();
self.selection.clear();
self.map_stamps.clear();
self.stale = true;
}
fn recount(&mut self) {
self.counts.clear();
self.selection.clear();
self.map_stamps.clear();
if !self.is_sifted() && self.marks.is_empty() {
return;
}
let mut out = Tallied {
counts: std::mem::take(&mut self.counts),
selection: std::mem::take(&mut self.selection),
map_stamps: std::mem::take(&mut self.map_stamps),
};
if self.maps() {
out.map_stamps.resize(self.tree.minted(), 0);
}
tally(
&self.tree,
&self.lens,
&self.marks,
&self.spared,
&self.moving,
&mut out,
);
self.counts = out.counts;
self.selection = out.selection;
self.map_stamps = out.map_stamps;
}
fn is_sifted(&self) -> bool {
!self.lens.is_everything()
}
fn descends_from(&self, id: NodeId, root: NodeId) -> bool {
descends_from(&self.tree, id, root)
}
}
fn descends_from(tree: &Tree, id: NodeId, root: NodeId) -> bool {
let mut at = Some(id);
while let Some(current) = at {
if current == root {
return true;
}
at = tree.node(current).parent;
}
false
}
fn kind_order(kind: Option<Kind>) -> usize {
kind.map_or(Kind::ALL.len(), Kind::cost)
}
#[must_use]
pub fn plural(count: usize, one: &str, many: &str) -> String {
format!("{count} {}", if count == 1 { one } else { many })
}
#[cfg(test)]
mod tests {
use super::{
Action, Answer, Effect, Maps, Mark, Motion, Notice, Overlay, Planned, Preset, Turn, View,
};
use crate::delete::{Refusal, Refused};
use crate::fixture::{gitignored, gitignored_file, hit, of_kind};
use crate::rules::Kind;
use crate::size::Size;
use crate::tree::{Order, Sort, Tree};
use crate::tui::moving::{ARRIVAL, COUNT_UP, DIM, FLASH, RUNG};
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
fn view() -> View {
let mut tree = Tree::new("/scan");
tree.insert(hit("/scan/nx/node_modules", Size::Measured(200), 900));
tree.insert(hit(
"/scan/nx/packages/ui/node_modules",
Size::Measured(100),
800,
));
tree.insert(hit("/scan/old/target", Size::Measured(10), 100));
let mut view = View::new(tree);
view.viewport(40);
view
}
fn shown(view: &View) -> Vec<String> {
view.rows()
.iter()
.map(|row| {
format!(
"{}{}",
" ".repeat(row.depth),
view.tree().node(row.id).name.to_string_lossy()
)
})
.collect()
}
fn at(view: &View, path: &str) -> crate::tree::NodeId {
view.tree().find(Path::new(path)).unwrap()
}
fn settle(view: &mut View) {
view.animate(Instant::now() + DIM * 2);
}
fn select(view: &mut View, path: &str) {
let want = PathBuf::from(path);
let at = view
.rows()
.iter()
.position(|row| view.tree().node(row.id).path == want)
.unwrap_or_else(|| panic!("{path} is not on screen"));
view.apply(Action::Cursor(Motion::Top));
for _ in 0..at {
view.apply(Action::Cursor(Motion::Down));
}
}
fn point_at(view: &mut View, path: &str) {
let mut above = PathBuf::from("/scan");
let below = Path::new(path).strip_prefix("/scan").unwrap().to_path_buf();
for component in below.components() {
let at = view.tree().find(&above).unwrap();
if !view.is_expanded(at) {
select(view, &above.to_string_lossy());
view.apply(Action::Expand);
}
above.push(component);
}
select(view, path);
}
#[test]
fn everything_but_the_root_starts_closed() {
let view = view();
assert_eq!(shown(&view), ["/scan", " nx", " old"]);
}
#[test]
fn a_row_opens_onto_its_own_children_only() {
let mut view = view();
point_at(&mut view, "/scan/nx");
view.apply(Action::Expand);
assert_eq!(
shown(&view),
["/scan", " nx", " node_modules", " packages", " old"]
);
}
#[test]
fn opening_an_open_row_steps_into_it_and_closing_a_closed_one_steps_out() {
let mut view = view();
point_at(&mut view, "/scan/nx");
view.apply(Action::Expand);
view.apply(Action::Expand);
assert_eq!(
view.tree().node(view.row().unwrap().id).path,
PathBuf::from("/scan/nx/node_modules")
);
view.apply(Action::Collapse);
assert_eq!(
view.tree().node(view.row().unwrap().id).path,
PathBuf::from("/scan/nx")
);
}
#[test]
fn the_star_key_opens_a_whole_subtree_and_z_closes_everything() {
let mut view = view();
point_at(&mut view, "/scan/nx");
view.apply(Action::ToggleSubtree);
assert_eq!(
shown(&view),
[
"/scan",
" nx",
" node_modules",
" packages",
" ui",
" node_modules",
" old",
]
);
view.apply(Action::CollapseAll);
assert_eq!(shown(&view), ["/scan", " nx", " old"]);
}
#[test]
fn levels_sort_within_themselves_rather_than_globally() {
let mut view = view();
point_at(&mut view, "/scan/nx");
view.apply(Action::ToggleSubtree);
view.apply(Action::SortBy(Order::Path));
assert_eq!(view.sort(), Sort::by(Order::Path));
assert_eq!(
shown(&view),
[
"/scan",
" nx",
" node_modules",
" packages",
" ui",
" node_modules",
" old",
]
);
}
#[test]
fn a_row_that_has_not_been_priced_reads_as_unpriced_rather_than_as_empty() {
let mut tree = Tree::new("/scan");
tree.insert(hit("/scan/nx/node_modules", Size::Unmeasured, 900));
let view = View::new(tree);
let roll = view.roll(at(&view, "/scan/nx"));
assert_eq!(roll.bytes, 0);
assert_eq!(roll.unpriced, 1);
assert_eq!(roll.label(), "—");
let mut view = view;
view.priced(Path::new("/scan/nx/node_modules"), Size::Measured(2048));
view.sync();
assert_eq!(view.roll(at(&view, "/scan/nx")).label(), "2.0 KiB");
}
#[test]
fn a_claim_that_arrives_under_a_closed_row_moves_its_total_and_not_the_cursor() {
let mut view = view();
point_at(&mut view, "/scan/old");
let before = view.total().bytes;
view.found(hit(
"/scan/nx/packages/api/node_modules",
Size::Measured(5),
1,
));
view.sync();
assert_eq!(view.total().bytes, before + 5);
assert_eq!(
view.tree().node(view.row().unwrap().id).path,
PathBuf::from("/scan/old"),
"an arrival elsewhere moved the cursor"
);
}
#[test]
fn a_claim_that_arrives_under_a_marked_row_is_marked_on_arrival() {
let mut view = view();
point_at(&mut view, "/scan/nx");
view.apply(Action::Mark);
assert_eq!(view.marked().claims, 2);
view.found(hit(
"/scan/nx/packages/api/node_modules",
Size::Measured(5),
1,
));
view.sync();
assert_eq!(view.marked().claims, 3);
assert_eq!(view.marked().bytes, 305);
assert!(batched(&view).contains(&PathBuf::from("/scan/nx/packages/api/node_modules")));
}
#[test]
fn the_cursor_stays_on_its_directory_when_a_price_re_sorts_the_level_under_it() {
let mut tree = Tree::new("/scan");
tree.insert(hit("/scan/nx/node_modules", Size::Measured(200), 900));
tree.insert(hit("/scan/old/target", Size::Unmeasured, 100));
let mut view = View::new(tree);
view.viewport(40);
point_at(&mut view, "/scan/old");
assert_eq!(view.cursor(), Some(2));
view.priced(Path::new("/scan/old/target"), Size::Measured(9000));
view.sync();
assert_eq!(shown(&view), ["/scan", " old", " nx"]);
assert_eq!(
view.tree().node(view.row().unwrap().id).path,
PathBuf::from("/scan/old")
);
assert_eq!(view.cursor(), Some(1));
}
#[test]
fn a_deleted_row_leaves_the_cursor_on_the_nearest_directory_that_is_still_there() {
let mut view = view();
point_at(&mut view, "/scan/nx");
view.apply(Action::ToggleSubtree);
point_at(&mut view, "/scan/nx/packages/ui/node_modules");
view.removed(Path::new("/scan/nx/packages/ui/node_modules"), 100, true);
settle(&mut view);
assert_eq!(
view.tree().node(view.row().unwrap().id).path,
PathBuf::from("/scan/nx")
);
}
#[test]
fn deleting_everything_a_reader_was_looking_at_lands_on_the_scan_root_and_not_on_a_stranger() {
let mut view = view();
point_at(&mut view, "/scan/old");
view.removed(Path::new("/scan/old/target"), 10, true);
settle(&mut view);
assert_eq!(
view.tree().node(view.row().unwrap().id).path,
PathBuf::from("/scan")
);
}
#[test]
fn a_cursor_whose_whole_ancestry_is_off_screen_is_deselected_rather_than_moved_to_row_zero() {
let mut view = view();
point_at(&mut view, "/scan/old");
filter(&mut view, "nothing matches this");
assert!(view.rows().is_empty());
assert_eq!(view.cursor(), None);
view.apply(Action::Back);
assert_eq!(view.cursor(), None);
view.found(hit("/scan/other/node_modules", Size::Measured(1), 1));
view.sync();
assert_eq!(view.cursor(), None);
view.apply(Action::Cursor(Motion::Down));
assert_eq!(view.cursor(), Some(0));
}
#[test]
fn a_target_the_deleter_could_not_finish_keeps_its_row() {
let mut view = view();
let before = view.total();
view.removed(Path::new("/scan/old/target"), 0, false);
view.sync();
assert_eq!(view.total(), before);
assert!(view.tree().find(Path::new("/scan/old/target")).is_some());
}
#[test]
fn marking_a_collapsed_row_marks_everything_beneath_it() {
let mut view = view();
point_at(&mut view, "/scan/nx");
view.apply(Action::Mark);
assert_eq!(view.mark_of(at(&view, "/scan/nx")), Mark::All);
assert_eq!(
view.mark_of(at(&view, "/scan/nx/packages/ui/node_modules")),
Mark::All
);
assert_eq!(view.mark_of(at(&view, "/scan/old/target")), Mark::None);
assert_eq!(
batched(&view),
[
PathBuf::from("/scan/nx/node_modules"),
PathBuf::from("/scan/nx/packages/ui/node_modules"),
]
);
}
#[test]
fn an_ancestor_of_a_mark_shows_a_partial_state() {
let mut view = view();
point_at(&mut view, "/scan/nx");
view.apply(Action::Expand);
point_at(&mut view, "/scan/nx/node_modules");
view.apply(Action::Mark);
assert_eq!(view.mark_of(at(&view, "/scan/nx")), Mark::Partial);
assert_eq!(view.mark_of(view.tree().root()), Mark::Partial);
assert_eq!(view.mark_of(at(&view, "/scan/old")), Mark::None);
}
#[test]
fn unmarking_one_row_out_of_a_marked_subtree_spares_it_and_keeps_the_rest() {
let mut view = view();
point_at(&mut view, "/scan/nx");
view.apply(Action::Mark);
view.apply(Action::ToggleSubtree);
point_at(&mut view, "/scan/nx/node_modules");
view.apply(Action::Mark);
assert_eq!(view.mark_of(at(&view, "/scan/nx/node_modules")), Mark::None);
assert_eq!(
view.mark_of(at(&view, "/scan/nx/packages/ui/node_modules")),
Mark::All
);
assert_eq!(view.mark_of(at(&view, "/scan/nx")), Mark::Partial);
assert_eq!(
batched(&view),
[PathBuf::from("/scan/nx/packages/ui/node_modules")]
);
}
#[test]
fn marking_a_row_absorbs_the_marks_already_inside_it() {
let mut view = view();
point_at(&mut view, "/scan/nx");
view.apply(Action::ToggleSubtree);
point_at(&mut view, "/scan/nx/node_modules");
view.apply(Action::Mark);
point_at(&mut view, "/scan/nx");
view.apply(Action::Mark);
assert_eq!(view.marked().claims, 2);
assert_eq!(view.marked().bytes, 300);
view.apply(Action::Mark);
assert_eq!(view.marked().claims, 0);
assert_eq!(view.mark_of(at(&view, "/scan/nx/node_modules")), Mark::None);
}
#[test]
fn a_key_marks_everything_and_the_same_key_clears_a_partial_selection() {
let mut view = view();
view.apply(Action::MarkAll);
assert_eq!(view.marked().claims, 3);
assert_eq!(view.marked().bytes, 310);
view.apply(Action::MarkAll);
assert_eq!(view.marked().claims, 0);
point_at(&mut view, "/scan/old");
view.apply(Action::Mark);
view.apply(Action::MarkAll);
assert_eq!(view.marked().claims, 0);
}
#[test]
fn a_mark_on_a_directory_the_deleter_has_taken_away_stops_counting() {
let mut view = view();
point_at(&mut view, "/scan/old");
view.apply(Action::Mark);
assert_eq!(view.marked().claims, 1);
view.removed(Path::new("/scan/old/target"), 10, true);
settle(&mut view);
assert_eq!(view.marked().claims, 0);
assert!(batched(&view).is_empty());
assert_eq!(view.mark_of(view.tree().root()), Mark::None);
}
fn mixed() -> View {
let mut tree = Tree::new("/scan");
tree.insert(sized(
of_kind("/scan/nx/node_modules", Kind::Dependencies),
200,
));
tree.insert(sized(of_kind("/scan/nx/dist", Kind::Build), 100));
tree.insert(sized(of_kind("/scan/nx/.nx/cache", Kind::Cache), 10));
tree.insert(sized(gitignored("/scan/nx/out"), 1));
tree.insert(sized(of_kind("/scan/old/target", Kind::Build), 20));
let mut view = View::new(tree);
view.viewport(40);
view
}
fn sized(mut made: crate::walk::Hit, bytes: u64) -> crate::walk::Hit {
made.size = Size::Measured(bytes);
made
}
fn shown_claims(view: &View) -> Vec<PathBuf> {
let mut found: Vec<PathBuf> = (0..view.tree().minted())
.filter(|&id| view.tree().is_attached(id))
.filter(|&id| view.tree().node(id).hit.is_some() && view.roll(id).claims > 0)
.map(|id| view.tree().node(id).path.clone())
.collect();
found.sort();
found
}
fn showing(view: &mut View, preset: Preset) {
for _ in 0..=Preset::ALL.len() {
if view.preset() == Some(preset) {
return;
}
view.apply(Action::CyclePreset(Turn::Next));
}
panic!("{preset} is not on the cycle");
}
#[test]
fn a_run_opens_on_default_and_the_header_says_what_default_leaves_out() {
let view = mixed();
assert_eq!(view.preset(), Some(Preset::Default));
assert_eq!(view.total().claims, 4);
assert_eq!(view.out_of_view(), 1);
assert_eq!(view.view_label(), "default");
}
#[test]
fn one_key_walks_the_four_views_that_were_asked_for_in_that_order() {
let mut view = mixed();
let seen = |view: &View| view.total().claims;
assert_eq!(view.preset(), Some(Preset::Default));
assert_eq!(seen(&view), 4);
view.apply(Action::CyclePreset(Turn::Next));
assert_eq!(view.preset(), Some(Preset::Dependencies));
assert_eq!(seen(&view), 1);
view.apply(Action::CyclePreset(Turn::Next));
assert_eq!(view.preset(), Some(Preset::AllIgnored));
assert_eq!(seen(&view), 2);
view.apply(Action::CyclePreset(Turn::Next));
assert_eq!(view.preset(), Some(Preset::All));
assert_eq!(seen(&view), 5);
assert_eq!(view.out_of_view(), 0);
view.apply(Action::CyclePreset(Turn::Next));
assert_eq!(view.preset(), Some(Preset::Default));
view.apply(Action::CyclePreset(Turn::Prev));
assert_eq!(view.preset(), Some(Preset::All));
}
#[test]
fn each_step_of_the_cycle_moves_one_axis_and_carries_the_other() {
let mut view = mixed();
let seen = |view: &View| shown_claims(view);
assert_eq!(view.view_label(), "default");
view.apply(Action::CyclePreset(Turn::Next));
assert_eq!(seen(&view), [PathBuf::from("/scan/nx/node_modules")]);
view.apply(Action::CyclePreset(Turn::Next));
assert_eq!(
seen(&view),
[
PathBuf::from("/scan/nx/node_modules"),
PathBuf::from("/scan/nx/out"),
],
"all-ignored dropped the kind narrowing instead of carrying it"
);
view.apply(Action::CyclePreset(Turn::Next));
assert_eq!(view.total().claims, 5);
}
#[test]
fn no_preset_touches_the_pattern() {
let mut view = mixed();
filter(&mut view, "nx");
for _ in 0..=Preset::ALL.len() {
view.apply(Action::CyclePreset(Turn::Next));
assert_eq!(view.filter(), Some("nx"), "{:?}", view.preset());
}
}
#[test]
fn the_two_axes_compose_rather_than_replacing_each_other() {
let mut view = mixed();
showing(&mut view, Preset::Default);
filter(&mut view, "nx");
assert_eq!(
view.total().claims,
3,
"the gitignored one is out either way"
);
assert_eq!(view.lens().describe(), "named · every kind · /nx");
}
#[test]
fn each_axis_has_a_key_of_its_own_so_a_non_preset_view_is_reachable() {
let mut view = mixed();
for kind in Kind::ALL.into_iter().filter(|&kind| kind != Kind::Cache) {
view.apply(Action::ToggleKind(kind));
}
assert_eq!(view.preset(), None, "a hand-built view is not a preset");
assert_eq!(view.view_label(), "named · cache");
assert_eq!(shown_claims(&view), [PathBuf::from("/scan/nx/.nx/cache")]);
view.apply(Action::CycleTiers);
assert_eq!(view.view_label(), "named + gitignored · cache");
assert_eq!(
shown_claims(&view),
[
PathBuf::from("/scan/nx/.nx/cache"),
PathBuf::from("/scan/nx/out"),
]
);
view.apply(Action::CycleTiers);
assert_eq!(view.view_label(), "gitignored · cache");
assert_eq!(shown_claims(&view), [PathBuf::from("/scan/nx/out")]);
}
#[test]
fn moving_either_axis_by_hand_leaves_the_selection_exactly_where_it_was() {
let mut view = mixed();
showing(&mut view, Preset::All);
point_at(&mut view, "/scan");
view.apply(Action::Mark);
let whole = batched(&view);
assert_eq!(whole.len(), 5);
for action in [
Action::ToggleKind(Kind::Dependencies),
Action::CycleTiers,
Action::ToggleKind(Kind::Cache),
Action::CycleTiers,
Action::ToggleKind(Kind::Build),
] {
view.apply(action);
assert_eq!(batched(&view), whole, "{action:?} changed the batch");
assert_eq!(view.marked().claims, 5, "{action:?} changed the counter");
}
assert_eq!(view.total().claims, 0);
assert_eq!(view.hidden(), 5);
assert_eq!(batched(&view).len(), 5);
}
#[test]
fn a_hand_built_view_that_lands_on_a_preset_is_called_by_its_name() {
let mut view = mixed();
for kind in Kind::ALL
.into_iter()
.filter(|&kind| kind != Kind::Dependencies)
{
view.apply(Action::ToggleKind(kind));
}
assert_eq!(view.preset(), Some(Preset::Dependencies));
assert_eq!(view.view_label(), "dependencies");
view.apply(Action::ToggleKind(Kind::Dependencies));
view.apply(Action::ToggleKind(Kind::Build));
view.apply(Action::CycleTiers);
assert_eq!(view.preset(), None);
assert_eq!(view.view_label(), "named + gitignored · build");
}
#[test]
fn toggling_what_is_visible_never_changes_what_is_selected() {
let mut view = mixed();
showing(&mut view, Preset::All);
point_at(&mut view, "/scan");
view.apply(Action::Mark);
let whole = batched(&view);
assert_eq!(whole.len(), 5);
for preset in Preset::ALL {
showing(&mut view, preset);
assert_eq!(batched(&view), whole, "{preset} changed the batch");
assert_eq!(view.marked().claims, 5, "{preset} changed the counter");
}
}
#[test]
fn a_mark_keeps_meaning_the_view_it_was_made_through() {
let mut view = mixed();
showing(&mut view, Preset::Dependencies);
point_at(&mut view, "/scan/nx");
view.apply(Action::Mark);
assert_eq!(batched(&view), [PathBuf::from("/scan/nx/node_modules")]);
showing(&mut view, Preset::All);
assert_eq!(
batched(&view),
[PathBuf::from("/scan/nx/node_modules")],
"widening the view widened the selection"
);
assert_eq!(view.mark_of(at(&view, "/scan/nx")), Mark::Partial);
}
#[test]
fn a_second_mark_through_a_second_view_adds_to_the_first() {
let mut view = mixed();
showing(&mut view, Preset::Dependencies);
point_at(&mut view, "/scan/nx");
view.apply(Action::Mark);
view.apply(Action::CycleTiers);
view.apply(Action::CycleTiers);
view.apply(Action::ToggleKind(Kind::Dependencies));
assert_eq!(view.view_label(), "gitignored · none");
point_at(&mut view, "/scan/nx");
view.apply(Action::Mark);
showing(&mut view, Preset::All);
assert_eq!(
batched(&view),
[
PathBuf::from("/scan/nx/node_modules"),
PathBuf::from("/scan/nx/out"),
]
);
}
#[test]
fn the_partial_glyph_is_computed_against_the_view_the_reader_is_looking_through() {
let mut view = mixed();
showing(&mut view, Preset::Dependencies);
point_at(&mut view, "/scan/nx");
view.apply(Action::Mark);
assert_eq!(view.mark_of(at(&view, "/scan/nx")), Mark::All);
assert!((view.share(at(&view, "/scan/nx")) - 1.0).abs() < f64::EPSILON);
showing(&mut view, Preset::All);
assert_eq!(view.mark_of(at(&view, "/scan/nx")), Mark::Partial);
assert!((view.share(at(&view, "/scan/nx")) - 200.0 / 311.0).abs() < 0.001);
view.apply(Action::Dismiss);
view.apply(Action::Back);
view.apply(Action::Back);
assert!(batched(&view).is_empty());
point_at(&mut view, "/scan/nx/dist");
view.apply(Action::Mark);
showing(&mut view, Preset::Dependencies);
assert_eq!(view.marked().claims, 1, "the selection is still there");
assert_eq!(view.mark_of(at(&view, "/scan/nx")), Mark::None);
}
#[test]
fn a_claim_that_streams_in_under_a_mark_joins_it_when_it_matches_that_marks_view() {
let mut view = mixed();
showing(&mut view, Preset::Dependencies);
point_at(&mut view, "/scan/nx");
view.apply(Action::Mark);
view.found(sized(
of_kind("/scan/nx/packages/ui/node_modules", Kind::Dependencies),
50,
));
view.found(sized(of_kind("/scan/nx/packages/ui/dist", Kind::Build), 50));
view.sync();
showing(&mut view, Preset::All);
assert_eq!(
batched(&view),
[
PathBuf::from("/scan/nx/node_modules"),
PathBuf::from("/scan/nx/packages/ui/node_modules"),
],
"a build artefact joined a dependencies mark"
);
}
#[test]
fn sparing_one_row_out_of_a_marked_subtree_keeps_sparing_it_as_more_arrives() {
let mut view = mixed();
point_at(&mut view, "/scan");
view.apply(Action::Mark);
point_at(&mut view, "/scan/nx/dist");
view.apply(Action::Mark);
assert!(!batched(&view).contains(&PathBuf::from("/scan/nx/dist")));
view.found(sized(of_kind("/scan/nx/late", Kind::Build), 5));
view.sync();
assert!(
batched(&view).contains(&PathBuf::from("/scan/nx/late")),
"a claim that arrived beside the spared row was spared too"
);
assert!(!batched(&view).contains(&PathBuf::from("/scan/nx/dist")));
}
#[test]
fn a_spared_subtree_can_be_marked_again_from_inside_it() {
let mut view = mixed();
point_at(&mut view, "/scan");
view.apply(Action::Mark);
point_at(&mut view, "/scan/nx");
view.apply(Action::Mark);
assert_eq!(batched(&view), [PathBuf::from("/scan/old/target")]);
point_at(&mut view, "/scan/nx/dist");
view.apply(Action::Mark);
assert_eq!(
batched(&view),
[
PathBuf::from("/scan/nx/dist"),
PathBuf::from("/scan/old/target"),
]
);
}
#[test]
fn unmarking_a_row_nothing_was_covering_leaves_no_exclusion_behind() {
let mut view = mixed();
point_at(&mut view, "/scan/nx");
view.apply(Action::Mark);
view.apply(Action::Mark);
assert!(batched(&view).is_empty());
assert!(view.marks.is_empty());
assert!(
view.spared.is_empty(),
"a plain unmark left an exclusion for an ancestor that does not exist"
);
}
#[test]
fn the_batch_is_the_whole_selection_and_the_counter_says_how_much_is_out_of_sight() {
let mut view = mixed();
showing(&mut view, Preset::All);
point_at(&mut view, "/scan");
view.apply(Action::Mark);
assert_eq!(view.marked().claims, 5);
assert_eq!(view.hidden(), 0);
showing(&mut view, Preset::Dependencies);
assert_eq!(view.marked().claims, 5, "the counter followed the view");
assert_eq!(batched(&view).len(), 5, "the batch followed the view");
assert_eq!(view.hidden(), 4);
assert!(
view.notice().unwrap().contains("out of sight"),
"{:?}",
view.notice()
);
}
#[test]
fn the_confirmation_lists_the_whole_batch_and_names_what_is_out_of_sight() {
let mut view = mixed();
showing(&mut view, Preset::All);
point_at(&mut view, "/scan");
view.apply(Action::Mark);
showing(&mut view, Preset::Dependencies);
let Effect::Plan(batch) = view.apply(Action::Commit) else {
panic!("the key that writes did not ask");
};
view.asking(
&batch
.iter()
.map(|target| Planned::at(target.path.clone(), target.size))
.collect::<Vec<_>>(),
&[],
);
let pending = view.pending().unwrap();
assert_eq!(pending.entries().len(), 5);
assert_eq!(pending.hidden(), 4);
let kinds: Vec<Option<Kind>> = pending.entries().iter().map(|entry| entry.kind).collect();
assert_eq!(
kinds,
[
Some(Kind::Dependencies),
Some(Kind::Build),
Some(Kind::Build),
Some(Kind::Cache),
None,
]
);
let seen: Vec<bool> = pending.entries().iter().map(|entry| entry.hidden).collect();
assert_eq!(seen, [false, true, true, true, true]);
}
#[test]
fn a_standing_report_does_not_stand_in_for_the_confirmation() {
let mut view = mixed();
showing(&mut view, Preset::All);
point_at(&mut view, "/scan");
view.apply(Action::Mark);
showing(&mut view, Preset::Dependencies);
assert_eq!(view.hidden(), 4);
view.deleted(
Notice::standing("removed 10 B from 1 directory, 1 directory left alone"),
10,
);
let Effect::Plan(batch) = view.apply(Action::Commit) else {
panic!("a report in the footer swallowed the batch");
};
assert!(view.notice_stands(), "the report went with the keystroke");
view.asking(
&batch
.iter()
.map(|target| Planned::at(target.path.clone(), target.size))
.collect::<Vec<_>>(),
&[],
);
let pending = view
.pending()
.expect("no confirmation over a standing report");
assert_eq!(pending.entries().len(), 5);
assert_eq!(pending.hidden(), 4);
assert!(view.notice().is_some());
}
#[test]
fn getting_rid_of_the_report_never_gets_rid_of_the_confirmation() {
let mut view = mixed();
showing(&mut view, Preset::All);
point_at(&mut view, "/scan");
view.apply(Action::Mark);
showing(&mut view, Preset::Dependencies);
view.deleted(Notice::standing("1 directory left alone"), 10);
view.asking(&planned(&["/scan/nx/node_modules", "/scan/nx/dist"]), &[]);
assert!(view.pending().is_some());
view.apply(Action::Dismiss);
assert_eq!(view.notice(), None);
assert!(
view.pending().is_some(),
"dismissing the report took the question with it"
);
view.deleted(Notice::standing("1 directory left alone"), 10);
view.asking(&planned(&["/scan/nx/node_modules"]), &[]);
view.apply(Action::Back);
assert!(view.pending().is_none(), "Esc did not take the box first");
assert!(view.notice().is_some(), "Esc took both rungs at once");
}
#[test]
fn an_entry_can_be_taken_out_of_the_batch_from_the_confirmation_itself() {
let mut view = mixed();
showing(&mut view, Preset::All);
point_at(&mut view, "/scan");
view.apply(Action::Mark);
showing(&mut view, Preset::Dependencies);
view.asking(&planned(&["/scan/nx/node_modules", "/scan/nx/dist"]), &[]);
view.apply(Action::Listing(Motion::Down));
assert_eq!(
view.pending().unwrap().entries()[view.pending().unwrap().at()].path,
PathBuf::from("/scan/nx/dist")
);
view.apply(Action::Spare);
let pending = view.pending().unwrap();
assert_eq!(pending.entries().len(), 1);
assert_eq!(pending.targets, [PathBuf::from("/scan/nx/node_modules")]);
assert!(!batched(&view).contains(&PathBuf::from("/scan/nx/dist")));
assert_eq!(view.hidden(), 3);
}
#[test]
fn taking_the_last_entry_out_closes_the_question_rather_than_asking_an_empty_one() {
let mut view = mixed();
point_at(&mut view, "/scan/nx/dist");
view.apply(Action::Mark);
view.asking(&planned(&["/scan/nx/dist"]), &[]);
view.apply(Action::Spare);
assert_eq!(view.overlay(), None);
assert!(view.notice().unwrap().contains("nothing left"));
assert!(batched(&view).is_empty());
}
#[test]
fn a_refused_directory_says_so_on_the_confirmation_rather_than_in_the_report() {
let mut view = mixed();
view.asking(
&planned(&["/scan/nx/node_modules"]),
&[Refused {
path: "/scan/old/target".into(),
reason: Refusal::HoldsCheckout,
}],
);
let pending = view.pending().unwrap();
assert_eq!(pending.kept(), 1);
let refused = pending
.entries()
.iter()
.find(|entry| entry.kept.is_some())
.unwrap();
assert_eq!(refused.path, PathBuf::from("/scan/old/target"));
assert!(refused.kept.as_ref().unwrap().contains("git checkout"));
assert_eq!(pending.targets, [PathBuf::from("/scan/nx/node_modules")]);
}
#[test]
fn escape_takes_the_pattern_off_before_the_view_and_the_marks_last_of_all() {
let mut view = mixed();
showing(&mut view, Preset::Dependencies);
filter(&mut view, "nx");
point_at(&mut view, "/scan");
view.apply(Action::Mark);
view.apply(Action::Back);
assert_eq!(view.filter(), None);
assert_eq!(view.preset(), Some(Preset::Dependencies));
view.apply(Action::Back);
assert_eq!(view.preset(), Some(Preset::Default));
assert!(!batched(&view).is_empty(), "the marks went with the view");
view.apply(Action::Back);
assert!(batched(&view).is_empty());
}
#[test]
fn a_filter_keeps_the_ancestors_of_what_it_matches() {
let mut view = view();
filter(&mut view, "ui/node_modules");
assert_eq!(view.filter(), Some("ui/node_modules"));
assert_eq!(shown(&view), ["/scan", " nx"]);
}
#[test]
fn a_filtered_row_is_worth_what_the_filter_shows_rather_than_what_is_under_it() {
let mut view = view();
filter(&mut view, "ui/node_modules");
assert_eq!(view.roll(at(&view, "/scan/nx")).bytes, 100);
assert_eq!(view.roll(at(&view, "/scan/nx")).claims, 1);
assert_eq!(view.total().bytes, 100);
}
#[test]
fn marking_a_filtered_row_never_deletes_what_the_filter_is_hiding() {
let mut view = view();
filter(&mut view, "ui/node_modules");
point_at(&mut view, "/scan/nx");
view.apply(Action::Mark);
assert_eq!(
batched(&view),
[PathBuf::from("/scan/nx/packages/ui/node_modules")]
);
assert_eq!(view.marked().bytes, 100);
}
#[test]
fn a_pattern_the_engine_refuses_leaves_the_prompt_up_and_says_why() {
let mut view = view();
view.apply(Action::OpenFilter);
for character in "node_(".chars() {
view.apply(Action::Type(character));
}
view.apply(Action::Submit);
assert_eq!(view.overlay(), Some(Overlay::Prompt));
assert!(view.prompt().unwrap().error().is_some());
assert_eq!(view.filter(), None);
}
#[test]
fn the_prompt_edits_like_a_text_field() {
let mut view = view();
view.apply(Action::OpenFilter);
for character in "node".chars() {
view.apply(Action::Type(character));
}
view.apply(Action::Caret(Motion::Top));
view.apply(Action::Type('x'));
assert_eq!(view.prompt().unwrap().text(), "xnode");
assert_eq!(view.prompt().unwrap().caret(), 1);
view.apply(Action::Erase);
assert_eq!(view.prompt().unwrap().text(), "node");
view.apply(Action::EraseAhead);
assert_eq!(view.prompt().unwrap().text(), "ode");
view.apply(Action::Wipe);
assert_eq!(view.prompt().unwrap().text(), "");
view.apply(Action::Submit);
assert_eq!(view.filter(), None);
assert_eq!(view.overlay(), None);
}
#[test]
fn the_delete_key_asks_before_anything_leaves_the_view() {
let mut view = view();
point_at(&mut view, "/scan/nx");
view.apply(Action::Mark);
let effect = view.apply(Action::Commit);
let Effect::Plan(targets) = effect else {
panic!("the delete key did something other than ask for a plan");
};
assert_eq!(
targets.iter().map(|t| t.path.clone()).collect::<Vec<_>>(),
[
PathBuf::from("/scan/nx/node_modules"),
PathBuf::from("/scan/nx/packages/ui/node_modules"),
]
);
assert_eq!(targets[0].size, Size::Measured(200));
assert_eq!(view.overlay(), None);
}
#[test]
fn the_confirmation_opens_on_cancel_and_enter_takes_the_highlighted_answer() {
let mut view = view();
view.asking(&planned(&["/scan/old/target"]), &[]);
assert_eq!(view.overlay(), Some(Overlay::Confirm));
assert_eq!(view.pending().unwrap().answer, Answer::Cancel);
assert_eq!(view.apply(Action::Answer), Effect::None);
assert_eq!(view.overlay(), None);
assert!(!view.is_deleting());
view.asking(&planned(&["/scan/old/target"]), &[]);
view.apply(Action::Highlight(Turn::Next));
assert_eq!(
view.apply(Action::Answer),
Effect::Delete(vec![PathBuf::from("/scan/old/target")])
);
assert!(view.is_deleting());
}
#[test]
fn the_deed_is_what_the_question_named_rather_than_what_is_marked_when_it_is_answered() {
let mut view = view();
view.asking(&planned(&["/scan/old/target"]), &[]);
view.found(hit("/scan/late/node_modules", Size::Measured(1), 1));
view.sync();
point_at(&mut view, "/scan/late");
view.apply(Action::Mark);
view.apply(Action::Highlight(Turn::Next));
assert_eq!(
view.apply(Action::Answer),
Effect::Delete(vec![PathBuf::from("/scan/old/target")])
);
}
#[test]
fn committing_nothing_says_so_instead_of_asking_an_empty_question() {
let mut view = view();
assert_eq!(view.apply(Action::Commit), Effect::None);
assert!(view.notice().unwrap().contains("nothing is marked"));
view.asking(
&[],
&[Refused {
path: "/scan/old/target".into(),
reason: Refusal::HoldsCheckout,
}],
);
assert_eq!(view.overlay(), None);
assert!(view.notice().unwrap().contains("left alone"));
}
#[test]
fn a_second_delete_while_one_is_running_is_refused_rather_than_racing_it() {
let mut view = view();
view.asking(&planned(&["/scan/old/target"]), &[]);
view.apply(Action::Highlight(Turn::Next));
view.apply(Action::Answer);
point_at(&mut view, "/scan/nx");
view.apply(Action::Mark);
assert_eq!(view.apply(Action::Commit), Effect::None);
assert!(view.notice().unwrap().contains("already running"));
}
#[test]
fn escape_walks_back_out_one_rung_at_a_time_and_never_quits() {
let mut view = view();
point_at(&mut view, "/scan/nx");
view.apply(Action::Mark);
filter(&mut view, "node_modules");
view.apply(Action::Help);
view.apply(Action::OpenFilter);
assert_eq!(view.apply(Action::Back), Effect::None);
assert_eq!(view.overlay(), Some(Overlay::Help));
view.apply(Action::Back);
assert_eq!(view.overlay(), None);
view.apply(Action::Back);
assert_eq!(view.filter(), None);
view.apply(Action::Back);
assert_eq!(view.marked().claims, 0);
assert_eq!(view.apply(Action::Back), Effect::None);
}
#[test]
fn m_on_a_terminal_that_reports_no_pixel_size_says_that_rather_than_blaming_the_protocol() {
let mut multiplexed = view();
multiplexed.allow_maps(Maps::Unmeasured);
multiplexed.apply(Action::ToggleMap);
let said = multiplexed.notice().unwrap();
assert!(said.contains("pixel size"), "{said}");
assert!(
!multiplexed.maps(),
"a map was turned on that cannot be drawn"
);
let mut plain = view();
plain.allow_maps(Maps::Unread);
plain.apply(Action::ToggleMap);
assert_eq!(
plain.notice(),
Some("this terminal does not read the graphics protocol, so there is no map")
);
}
#[test]
fn a_window_that_loses_its_pixel_size_gives_the_columns_back_and_says_why() {
let mut view = view();
view.allow_maps(Maps::Can);
assert!(view.maps());
view.allow_maps(Maps::Unmeasured);
assert!(!view.maps(), "the tree is still paying for the pane");
assert!(view.notice().unwrap().contains("pixel size"));
view.apply(Action::Back);
view.allow_maps(Maps::Can);
assert!(view.maps());
assert_eq!(
view.notice(),
None,
"it announced a map that is simply back"
);
}
#[test]
fn being_told_the_same_answer_again_is_not_news() {
let mut view = view();
view.allow_maps(Maps::Unmeasured);
view.apply(Action::Back);
assert_eq!(view.notice(), None);
for _ in 0..10 {
view.allow_maps(Maps::Unmeasured);
}
assert_eq!(view.notice(), None, "it said it again");
}
#[test]
fn a_terminal_that_never_could_draw_one_says_nothing_at_start_up() {
let mut view = view();
view.allow_maps(Maps::Unmeasured);
assert_eq!(view.notice(), None);
}
#[test]
fn a_report_of_what_was_removed_can_be_got_rid_of() {
let mut view = view();
view.deleted(Notice::passing("removed 10 B from 1 directory"), 10);
assert_eq!(view.notice(), Some("removed 10 B from 1 directory"));
view.apply(Action::Back);
assert_eq!(view.notice(), None);
}
#[test]
fn the_next_thing_the_reader_does_takes_an_ordinary_report_away() {
let mut view = view();
view.deleted(Notice::passing("removed 10 B from 1 directory"), 10);
view.apply(Action::Cursor(Motion::Down));
assert_eq!(view.notice(), None);
}
#[test]
fn a_key_nobody_bound_is_not_the_reader_having_read_the_report() {
let mut view = view();
view.deleted(Notice::passing("removed 10 B from 1 directory"), 10);
view.apply(Action::Ignore);
assert!(view.notice().is_some());
}
#[test]
fn a_report_naming_a_refusal_outlives_the_keys_that_clear_an_ordinary_one() {
let mut view = view();
view.deleted(
Notice::standing("removed 10 B from 1 directory, 1 directory failed"),
10,
);
assert!(view.notice_stands());
for action in [
Action::Cursor(Motion::Down),
Action::Expand,
Action::CycleSort,
Action::Mark,
] {
view.apply(action);
assert!(view.notice().is_some(), "{action:?} took it away");
}
view.apply(Action::Back);
assert_eq!(view.notice(), None);
assert!(!view.notice_stands());
}
#[test]
fn a_newer_report_answers_the_keystroke_that_asked_for_it_even_over_a_standing_one() {
let mut view = view();
view.deleted(
Notice::standing("removed 10 B from 1 directory, 1 failed"),
10,
);
assert_eq!(view.apply(Action::Commit), Effect::None);
assert!(view.notice().unwrap().contains("nothing is marked"));
assert!(!view.notice_stands());
}
#[test]
fn dismissing_a_report_is_one_rung_and_does_not_also_drop_the_filter() {
let mut view = view();
point_at(&mut view, "/scan/nx");
view.apply(Action::Mark);
filter(&mut view, "node_modules");
view.deleted(Notice::passing("removed 10 B from 1 directory"), 10);
view.apply(Action::Back);
assert_eq!(view.notice(), None);
assert!(view.filter().is_some());
assert_ne!(view.marked().claims, 0);
view.apply(Action::Back);
assert_eq!(view.filter(), None);
view.apply(Action::Back);
assert_eq!(view.marked().claims, 0);
}
#[test]
fn a_press_on_a_report_that_has_already_gone_does_not_take_the_filter_with_it() {
let mut view = view();
filter(&mut view, "node_modules");
view.deleted(Notice::passing("removed 10 B from 1 directory"), 10);
view.apply(Action::Cursor(Motion::Down));
assert_eq!(view.notice(), None);
view.apply(Action::Dismiss);
assert!(view.filter().is_some(), "the dismissal fell through");
}
#[test]
fn an_overlay_is_dismissed_before_the_report_behind_it() {
let mut view = view();
view.deleted(
Notice::standing("removed 10 B from 1 directory, 1 failed"),
10,
);
view.apply(Action::Help);
view.apply(Action::Back);
assert_eq!(view.overlay(), None);
assert!(view.notice().is_some(), "the help took the report with it");
view.apply(Action::Back);
assert_eq!(view.notice(), None);
}
#[test]
fn the_clock_that_drives_everything_else_on_the_frame_does_not_reach_the_report() {
let mut view = view();
let start = Instant::now();
view.deleted(
Notice::standing("removed 10 B from 1 directory, 1 directory failed"),
10,
);
for tick in 1..=600 {
view.animate(start + Duration::from_millis(100) * tick);
}
assert!(view.notice().is_some(), "a clock took the report away");
assert!(view.notice_stands());
view.apply(Action::Dismiss);
view.deleted(Notice::passing("removed 10 B from 1 directory"), 20);
for tick in 1..=600 {
view.animate(start + Duration::from_millis(100) * tick);
}
assert!(view.notice().is_some(), "a clock took the report away");
}
#[test]
fn the_viewport_follows_the_cursor_and_never_hangs_off_the_end_of_the_rows() {
let mut tree = Tree::new("/scan");
for n in 0..30 {
tree.insert(hit(
&format!("/scan/p{n:02}/node_modules"),
Size::Measured(1),
0,
));
}
let mut view = View::new(tree);
view.viewport(10);
assert_eq!(view.scroll(), 0);
view.apply(Action::Cursor(Motion::Bottom));
assert_eq!(view.cursor(), Some(30));
assert_eq!(
view.scroll(),
21,
"the cursor is off the bottom of the pane"
);
view.apply(Action::Cursor(Motion::PageUp));
assert_eq!(view.cursor(), Some(20));
assert_eq!(view.scroll(), 20);
filter(&mut view, "matches nothing at all");
assert_eq!(view.scroll(), 0);
}
#[test]
fn a_click_selects_the_directory_it_landed_on_and_not_the_position_it_was_at() {
let mut view = view();
point_at(&mut view, "/scan/nx");
view.apply(Action::Expand);
let old = at(&view, "/scan/old");
assert_eq!(
shown(&view),
["/scan", " nx", " node_modules", " packages", " old"]
);
view.found(hit("/scan/old/big/node_modules", Size::Measured(9_000), 50));
view.sync();
assert_eq!(
shown(&view),
["/scan", " old", " nx", " node_modules", " packages"]
);
view.apply(Action::Select(old));
assert_eq!(
view.tree().node(view.row().unwrap().id).path,
PathBuf::from("/scan/old")
);
}
#[test]
fn a_click_on_a_row_that_is_gone_leaves_the_cursor_where_it_is() {
let mut view = view();
point_at(&mut view, "/scan/nx");
let target = at(&view, "/scan/old/target");
view.removed(Path::new("/scan/old/target"), 10, true);
settle(&mut view);
view.apply(Action::Select(target));
assert_eq!(
view.tree().node(view.row().unwrap().id).path,
PathBuf::from("/scan/nx")
);
}
#[test]
fn a_click_on_the_indicator_opens_the_row_and_a_click_on_a_leafs_does_nothing_but_select() {
let mut view = view();
let nx = at(&view, "/scan/nx");
view.apply(Action::OpenRow(nx));
assert_eq!(
shown(&view),
["/scan", " nx", " node_modules", " packages", " old"]
);
view.apply(Action::OpenRow(nx));
assert_eq!(shown(&view), ["/scan", " nx", " old"]);
view.apply(Action::OpenRow(nx));
let leaf = at(&view, "/scan/nx/node_modules");
view.apply(Action::OpenRow(leaf));
assert_eq!(
view.tree().node(view.row().unwrap().id).path,
PathBuf::from("/scan/nx/node_modules")
);
assert_eq!(
shown(&view),
["/scan", " nx", " node_modules", " packages", " old"]
);
}
#[test]
fn a_click_on_the_box_marks_exactly_what_the_key_marks() {
let mut view = view();
let nx = at(&view, "/scan/nx");
view.apply(Action::MarkRow(nx));
assert_eq!(view.mark_of(nx), Mark::All);
assert_eq!(view.marked().claims, 2);
assert_eq!(
view.tree().node(view.row().unwrap().id).path,
PathBuf::from("/scan/nx")
);
view.apply(Action::MarkRow(nx));
assert_eq!(view.marked().claims, 0);
}
#[test]
fn naming_an_order_twice_turns_it_upside_down_and_a_new_column_starts_the_right_way_up() {
let mut view = view();
assert_eq!(view.sort(), Sort::by(Order::Size));
view.apply(Action::SortBy(Order::Path));
assert_eq!(view.sort(), Sort::by(Order::Path));
view.apply(Action::SortBy(Order::Path));
assert_eq!(
view.sort(),
Sort {
by: Order::Path,
reverse: true
}
);
view.apply(Action::SortBy(Order::Size));
assert_eq!(view.sort(), Sort::by(Order::Size));
}
#[test]
fn the_wheel_moves_the_viewport_and_takes_the_cursor_with_it() {
let mut tree = Tree::new("/scan");
for n in 0..30 {
tree.insert(hit(
&format!("/scan/p{n:02}/node_modules"),
Size::Measured(1),
0,
));
}
let mut view = View::new(tree);
view.viewport(10);
view.apply(Action::Cursor(Motion::Top));
assert_eq!((view.scroll(), view.cursor()), (0, Some(0)));
view.apply(Action::ScrollRows(Motion::Down));
assert_eq!((view.scroll(), view.cursor()), (3, Some(3)));
view.apply(Action::ScrollRows(Motion::Up));
assert_eq!(view.scroll(), 0);
assert_eq!(view.cursor(), Some(3));
for _ in 0..40 {
view.apply(Action::ScrollRows(Motion::Down));
}
assert_eq!(view.scroll(), view.rows().len() - 10);
}
#[test]
fn a_wheel_over_a_view_with_no_cursor_does_not_hand_it_one() {
let mut view = view();
filter(&mut view, "nothing matches this");
view.apply(Action::Back);
assert_eq!(view.cursor(), None);
view.apply(Action::ScrollRows(Motion::Down));
assert_eq!(view.cursor(), None, "scrolling chose a row");
}
#[test]
fn a_double_click_asks_for_a_price_on_what_is_under_the_row_and_nothing_else() {
let mut tree = Tree::new("/scan");
tree.insert(hit("/scan/nx/node_modules", Size::Unmeasured, 900));
tree.insert(hit(
"/scan/nx/packages/ui/node_modules",
Size::Measured(5),
800,
));
tree.insert(hit("/scan/old/target", Size::Unmeasured, 100));
let mut view = View::new(tree);
view.viewport(40);
let nx = at(&view, "/scan/nx");
let effect = view.apply(Action::Price(nx));
assert_eq!(
effect,
Effect::Price(vec![PathBuf::from("/scan/nx/node_modules")])
);
assert!(view.notice().unwrap().contains("pricing 1 directory"));
point_at(&mut view, "/scan/nx/packages");
let packages = at(&view, "/scan/nx/packages");
assert_eq!(view.apply(Action::Price(packages)), Effect::None);
assert!(
view.notice().unwrap().contains("already carries a price"),
"{:?}",
view.notice()
);
}
#[test]
fn a_double_click_on_a_row_that_has_gone_prices_nothing() {
let mut tree = Tree::new("/scan");
tree.insert(hit("/scan/old/target", Size::Unmeasured, 100));
let mut view = View::new(tree);
view.viewport(40);
point_at(&mut view, "/scan/old");
view.apply(Action::Expand);
let target = at(&view, "/scan/old/target");
view.removed(Path::new("/scan/old/target"), 0, true);
settle(&mut view);
assert_eq!(view.apply(Action::Price(target)), Effect::None);
}
#[test]
fn a_subtree_already_being_priced_is_not_asked_for_a_second_time() {
let mut tree = Tree::new("/scan");
tree.insert(hit("/scan/nx/node_modules", Size::Unmeasured, 900));
let mut view = View::new(tree);
view.viewport(40);
let nx = at(&view, "/scan/nx");
let claim = PathBuf::from("/scan/nx/node_modules");
assert_eq!(
view.apply(Action::Price(nx)),
Effect::Price(vec![claim.clone()])
);
assert_eq!(view.apply(Action::Price(nx)), Effect::None);
assert!(
view.notice().unwrap().contains("already being priced"),
"{:?}",
view.notice()
);
view.priced(&claim, Size::Measured(64));
view.repriced(&[claim], Notice::passing("priced 1 directory"));
assert_eq!(view.apply(Action::Price(nx)), Effect::None);
assert!(
view.notice().unwrap().contains("already carries a price"),
"{:?}",
view.notice()
);
}
#[test]
fn a_pricing_pass_that_never_reports_does_not_strand_its_rows() {
let mut tree = Tree::new("/scan");
tree.insert(hit("/scan/nx/node_modules", Size::Unmeasured, 900));
let mut view = View::new(tree);
view.viewport(40);
let nx = at(&view, "/scan/nx");
let claim = PathBuf::from("/scan/nx/node_modules");
view.apply(Action::Price(nx));
view.repriced(
std::slice::from_ref(&claim),
Notice::passing("the pricing went away"),
);
assert_eq!(view.notice(), Some("the pricing went away"));
assert_eq!(view.apply(Action::Price(nx)), Effect::Price(vec![claim]));
}
#[test]
fn a_double_click_never_prices_what_the_filter_is_hiding() {
let mut tree = Tree::new("/scan");
tree.insert(hit("/scan/nx/node_modules", Size::Unmeasured, 900));
tree.insert(hit(
"/scan/nx/packages/ui/node_modules",
Size::Unmeasured,
800,
));
let mut view = View::new(tree);
view.viewport(40);
filter(&mut view, "ui/node_modules");
let nx = at(&view, "/scan/nx");
assert_eq!(
view.apply(Action::Price(nx)),
Effect::Price(vec![PathBuf::from("/scan/nx/packages/ui/node_modules")])
);
}
#[test]
fn the_footer_stops_saying_a_price_is_being_worked_out_once_it_is() {
let mut tree = Tree::new("/scan");
tree.insert(hit("/scan/nx/node_modules", Size::Unmeasured, 900));
let mut view = View::new(tree);
let nx = at(&view, "/scan/nx");
view.apply(Action::Price(nx));
assert!(view.notice().unwrap().contains("pricing"));
view.repriced(
&[PathBuf::from("/scan/nx/node_modules")],
Notice::passing("priced 1 directory"),
);
assert_eq!(view.notice(), Some("priced 1 directory"));
}
#[test]
fn quitting_is_the_only_thing_that_ends_the_view() {
let mut view = view();
assert_eq!(view.apply(Action::Quit), Effect::Quit);
}
#[test]
fn quitting_cannot_end_a_view_that_is_half_way_through_a_removal() {
let mut view = view();
view.asking(&planned(&["/scan/old/target"]), &[]);
view.apply(Action::Highlight(Turn::Next));
view.apply(Action::Answer);
assert!(view.is_deleting());
assert_eq!(view.apply(Action::Quit), Effect::None);
assert!(!view.wants_to_quit());
assert!(view.notice().unwrap().contains("has to finish"));
assert_eq!(view.apply(Action::Quit), Effect::None);
assert!(!view.wants_to_quit());
view.deleted(Notice::passing("removed 10 B from 1 directory"), 10);
assert!(view.wants_to_quit());
}
#[test]
fn a_view_that_was_never_asked_to_quit_does_not_want_to() {
let mut view = view();
assert!(!view.wants_to_quit());
view.asking(&planned(&["/scan/old/target"]), &[]);
view.apply(Action::Highlight(Turn::Next));
view.apply(Action::Answer);
view.deleted(Notice::passing("removed 10 B from 1 directory"), 10);
assert!(!view.wants_to_quit());
}
#[test]
fn a_rolled_up_total_climbs_toward_what_arrived_rather_than_snapping_to_it() {
let mut view = view();
let start = Instant::now();
view.animate(start);
assert_eq!(view.drawn_total().bytes, 310);
view.found(hit("/scan/big/node_modules", Size::Measured(690), 1));
view.animate(start + COUNT_UP / 2);
assert_eq!(view.total().bytes, 1000);
let climbing = view.drawn_total().bytes;
assert!(climbing > 310 && climbing < 1000, "{climbing}");
assert!(view.is_moving());
view.animate(start + COUNT_UP * 8);
assert_eq!(view.drawn_total().bytes, 1000);
assert!(
!view.is_moving(),
"a settled view is still asking for frames"
);
}
#[test]
fn a_row_the_walk_has_just_found_is_lit_and_the_light_goes_out() {
let mut view = view();
let start = Instant::now();
view.animate(start);
assert!(view.freshness(at(&view, "/scan/nx")).abs() < f64::EPSILON);
view.found(hit("/scan/late/node_modules", Size::Measured(1), 1));
view.animate(start);
let late = at(&view, "/scan/late");
assert!(view.freshness(late) > 0.9, "{}", view.freshness(late));
assert!(view.freshness(at(&view, "/scan/nx")).abs() < f64::EPSILON);
view.animate(start + ARRIVAL * 2);
assert!(view.freshness(late).abs() < f64::EPSILON);
}
#[test]
fn an_ancestor_whose_children_are_still_being_priced_says_its_number_is_a_floor() {
let mut tree = Tree::new("/scan");
tree.insert(hit("/scan/nx/a/node_modules", Size::Measured(4200), 1));
tree.insert(hit("/scan/nx/b/node_modules", Size::Unmeasured, 1));
let mut view = View::new(tree);
let nx = at(&view, "/scan/nx");
assert_eq!(view.roll(nx).label(), "> 4.1 KiB");
view.priced(Path::new("/scan/nx/b/node_modules"), Size::Measured(700));
view.sync();
assert_eq!(view.roll(nx).label(), "4.8 KiB");
}
#[test]
fn only_the_claims_a_pricing_thread_is_inside_are_hot() {
let mut tree = Tree::new("/scan");
tree.insert(hit("/scan/a/node_modules", Size::Unmeasured, 1));
tree.insert(hit("/scan/b/node_modules", Size::Unmeasured, 1));
let mut view = View::new(tree);
let a = at(&view, "/scan/a/node_modules");
let b = at(&view, "/scan/b/node_modules");
assert!(!view.is_pricing(a) && !view.is_pricing(b));
view.pricing(Path::new("/scan/a/node_modules"));
assert!(view.is_pricing(a));
assert!(!view.is_pricing(b));
view.priced(Path::new("/scan/a/node_modules"), Size::Measured(64));
assert!(!view.is_pricing(a));
}
#[test]
fn a_walk_that_has_finished_leaves_nothing_shimmering_for_a_thread_that_is_gone() {
let mut tree = Tree::new("/scan");
tree.insert(hit("/scan/a/node_modules", Size::Unmeasured, 1));
let mut view = View::new(tree);
let a = at(&view, "/scan/a/node_modules");
view.pricing(Path::new("/scan/a/node_modules"));
view.scanned();
assert!(!view.is_pricing(a));
assert!(!view.is_moving());
}
#[test]
fn a_claim_deleted_while_it_was_being_priced_stops_shimmering() {
let mut view = view();
let start = Instant::now();
view.animate(start);
view.pricing(Path::new("/scan/old/target"));
assert!(view.is_moving());
view.removed(Path::new("/scan/old/target"), 10, true);
view.animate(start + DIM);
assert!(view.tree().find(Path::new("/scan/old/target")).is_none());
assert!(!view.is_moving());
}
#[test]
fn marking_a_row_runs_the_mark_up_its_ancestors_rather_than_flashing_all_of_them() {
let mut view = view();
let start = Instant::now();
view.animate(start);
point_at(&mut view, "/scan/nx");
view.apply(Action::Mark);
view.animate(start);
let root = view.tree().root();
assert!(view.is_cascading(at(&view, "/scan/nx")));
assert!(!view.is_cascading(root), "the whole chain flashed at once");
view.animate(start + RUNG);
assert!(view.is_cascading(root), "the mark never reached the root");
view.animate(start + RUNG + FLASH);
assert!(!view.is_cascading(root));
assert!(!view.is_moving());
}
#[test]
fn a_partial_ancestor_says_what_share_of_its_bytes_is_marked() {
let mut view = view();
point_at(&mut view, "/scan/nx");
view.apply(Action::Expand);
point_at(&mut view, "/scan/nx/node_modules");
view.apply(Action::Mark);
assert!((view.share(at(&view, "/scan/nx")) - 200.0 / 300.0).abs() < 1e-9);
assert!((view.share(view.tree().root()) - 200.0 / 310.0).abs() < 1e-9);
assert!(view.share(at(&view, "/scan/old")).abs() < f64::EPSILON);
assert!((view.share(at(&view, "/scan/nx/node_modules")) - 1.0).abs() < f64::EPSILON);
}
#[test]
fn a_share_that_cannot_be_stated_in_bytes_is_stated_in_claims() {
let mut tree = Tree::new("/scan");
tree.insert(hit("/scan/a/node_modules", Size::Measured(1000), 1));
tree.insert(hit("/scan/b/node_modules", Size::Unmeasured, 1));
let mut view = View::new(tree);
point_at(&mut view, "/scan/a");
view.apply(Action::Mark);
assert!((view.share(view.tree().root()) - 0.5).abs() < 1e-9);
view.priced(Path::new("/scan/b/node_modules"), Size::Measured(1000));
view.sync();
assert!((view.share(view.tree().root()) - 0.5).abs() < 1e-9);
}
#[test]
fn a_row_empties_on_the_bytes_the_deleter_says_have_gone() {
let mut view = view();
let start = Instant::now();
view.animate(start);
point_at(&mut view, "/scan/nx");
view.apply(Action::Expand);
let claim = at(&view, "/scan/nx/node_modules");
view.freeing(Path::new("/scan/nx/node_modules"), 100);
view.animate(start);
assert!(view.is_freeing(claim));
assert!(!view.is_spent(claim), "dimmed while it is still emptying");
assert_eq!(view.drawn(claim).bytes, 100);
assert_eq!(view.drawn(at(&view, "/scan/nx")).bytes, 200);
view.freeing(Path::new("/scan/nx/node_modules"), 180);
view.animate(start + Duration::from_millis(10));
assert_eq!(view.drawn(claim).bytes, 20);
view.removed(Path::new("/scan/nx/node_modules"), 200, true);
view.animate(start + Duration::from_millis(20));
assert!(view.is_spent(claim));
assert!(!view.is_freeing(claim));
assert_eq!(view.drawn(claim).bytes, 0);
assert!(
view.tree()
.find(Path::new("/scan/nx/node_modules"))
.is_some()
);
view.animate(start + Duration::from_millis(20) + DIM);
assert!(
view.tree()
.find(Path::new("/scan/nx/node_modules"))
.is_none()
);
assert_eq!(view.drawn(at(&view, "/scan/nx")).bytes, 100);
}
#[test]
fn a_row_the_sweep_could_not_finish_keeps_what_is_left_of_it() {
let mut view = view();
let start = Instant::now();
view.animate(start);
point_at(&mut view, "/scan/nx");
view.apply(Action::Expand);
let claim = at(&view, "/scan/nx/node_modules");
view.removed(Path::new("/scan/nx/node_modules"), 150, false);
view.animate(start + DIM * 2);
assert!(
view.tree()
.find(Path::new("/scan/nx/node_modules"))
.is_some()
);
assert!(!view.is_spent(claim), "a row that survived was collapsed");
assert_eq!(view.drawn(claim).bytes, 50);
}
#[test]
fn a_running_removal_counts_targets_against_the_batch_it_was_given() {
let mut view = view();
view.asking(
&priced(&[
("/scan/nx/node_modules", 200),
("/scan/nx/packages/ui/node_modules", 100),
("/scan/old/target", 10),
]),
&[],
);
view.apply(Action::Highlight(Turn::Next));
view.apply(Action::Answer);
assert_eq!(view.removing().unwrap().counted(), (0, 3));
assert_eq!(view.removing().unwrap().percent(), 0);
assert_eq!(view.removing().unwrap().weighed(), Some((0, 310)));
assert_eq!(
view.removing().unwrap().label(),
"removing 0 of 3 directories · 0% · 0 B of 310 B"
);
view.removed(Path::new("/scan/nx/node_modules"), 200, true);
assert_eq!(
view.removing().unwrap().counted(),
(0, 3),
"the position moved on what happened to a row"
);
assert_eq!(view.removing().unwrap().weighed(), Some((200, 310)));
view.swept(Path::new("/scan/nx/node_modules"));
assert_eq!(view.removing().unwrap().counted(), (1, 3));
view.removed(Path::new("/scan/nx/packages/ui/node_modules"), 40, false);
view.swept(Path::new("/scan/nx/packages/ui/node_modules"));
assert_eq!(view.removing().unwrap().counted(), (2, 3));
assert_eq!(view.removing().unwrap().percent(), 66);
assert_eq!(view.removing().unwrap().weighed(), Some((240, 310)));
view.swept(Path::new("/scan/old/target"));
assert_eq!(view.removing().unwrap().counted(), (3, 3));
assert_eq!(view.removing().unwrap().percent(), 100);
assert_eq!(view.removing().unwrap().weighed(), Some((240, 310)));
view.deleted(Notice::passing("removed 240 B from 1 directory"), 240);
assert!(view.removing().is_none());
assert!(!view.is_deleting());
}
#[test]
fn the_footer_names_the_target_the_batch_is_waiting_on() {
let mut view = view();
view.asking(
&priced(&[
("/scan/nx/node_modules", 200),
("/scan/nx/packages/ui/node_modules", 100),
("/scan/old/target", 10),
]),
&[],
);
view.apply(Action::Highlight(Turn::Next));
view.apply(Action::Answer);
assert_eq!(view.removing().unwrap().busiest(), None);
view.freeing(Path::new("/scan/old/target"), 4);
assert_eq!(
view.removing().unwrap().busiest(),
Some(Path::new("/scan/old/target"))
);
view.freeing(Path::new("/scan/nx/node_modules"), 8);
assert_eq!(
view.removing().unwrap().busiest(),
Some(Path::new("/scan/nx/node_modules")),
"the smaller target was named while a larger one was still going"
);
view.freeing(Path::new("/scan/old/target"), 10);
assert_eq!(
view.removing().unwrap().busiest(),
Some(Path::new("/scan/nx/node_modules"))
);
view.swept(Path::new("/scan/nx/node_modules"));
assert_eq!(
view.removing().unwrap().busiest(),
Some(Path::new("/scan/old/target"))
);
view.swept(Path::new("/scan/old/target"));
assert_eq!(view.removing().unwrap().busiest(), None);
}
#[test]
fn an_unpriced_batch_gives_no_byte_figure_rather_than_a_misleading_one() {
let mut view = View::new(Tree::new("/scan"));
view.viewport(10);
view.found(hit("/scan/app/node_modules", Size::Unmeasured, 0));
view.asking(
&[Planned::at("/scan/app/node_modules", Size::Unmeasured)],
&[],
);
view.apply(Action::Highlight(Turn::Next));
view.apply(Action::Answer);
assert_eq!(view.removing().unwrap().weighed(), None);
assert_eq!(
view.removing().unwrap().label(),
"removing 0 of 1 directory · 0%"
);
view.freeing(Path::new("/scan/app/node_modules"), 512);
assert_eq!(
view.removing().unwrap().busiest(),
Some(Path::new("/scan/app/node_modules"))
);
}
#[test]
fn a_batch_that_fails_on_everything_still_shows_the_deleter_working_through_it() {
let mut view = view();
view.asking(
&priced(&[
("/scan/nx/node_modules", 200),
("/scan/nx/packages/ui/node_modules", 100),
("/scan/old/target", 10),
]),
&[],
);
view.apply(Action::Highlight(Turn::Next));
view.apply(Action::Answer);
for (done, path) in [
"/scan/nx/node_modules",
"/scan/nx/packages/ui/node_modules",
"/scan/old/target",
]
.iter()
.enumerate()
{
view.swept(Path::new(path));
assert_eq!(view.removing().unwrap().counted(), (done + 1, 3));
}
assert_eq!(view.removing().unwrap().percent(), 100);
assert_eq!(view.removing().unwrap().weighed(), Some((0, 310)));
assert_eq!(
view.removing().unwrap().label(),
"removing 3 of 3 directories · 100% · 0 B of 310 B"
);
assert_eq!(view.roll(view.tree().root()).bytes, 310);
assert_eq!(view.roll(view.tree().root()).claims, 3);
}
#[test]
fn a_second_removal_is_refused_while_one_is_running() {
let mut view = view();
view.asking(&planned(&["/scan/old/target"]), &[]);
view.apply(Action::Highlight(Turn::Next));
view.apply(Action::Answer);
point_at(&mut view, "/scan/nx");
view.apply(Action::Mark);
assert_eq!(view.apply(Action::Commit), Effect::None);
assert_eq!(view.notice(), Some("a removal is already running"));
}
#[test]
fn a_part_emptied_row_does_not_spring_back_when_the_batch_reports() {
let mut view = view();
let start = Instant::now();
view.animate(start);
point_at(&mut view, "/scan/nx");
view.apply(Action::Expand);
let claim = at(&view, "/scan/nx/node_modules");
let root = view.tree().root();
view.freeing(Path::new("/scan/nx/node_modules"), 150);
view.removed(Path::new("/scan/nx/node_modules"), 150, false);
view.animate(start);
assert_eq!(view.drawn(claim).bytes, 50);
assert_eq!(view.drawn_total().bytes, 160);
view.deleted(Notice::standing("freed 150 B"), 150);
view.animate(start + DIM * 2);
assert_eq!(view.drawn(claim).bytes, 50, "the row sprang back");
assert_eq!(view.drawn_total().bytes, 160, "the headline rose again");
assert_eq!(view.roll(claim).bytes, 50);
assert_eq!(view.roll(root).bytes, 160);
assert_eq!(view.drawn_freed(), 150);
}
#[test]
fn a_row_the_deleter_has_touched_is_out_of_the_batch_and_out_of_the_counter() {
let mut view = view();
let start = Instant::now();
view.animate(start);
point_at(&mut view, "/scan/nx");
view.apply(Action::Mark);
assert_eq!(view.marked().claims, 2);
view.freeing(Path::new("/scan/nx/node_modules"), 200);
view.animate(start);
assert_eq!(view.marked().claims, 2);
assert_eq!(view.marked().bytes, 100);
assert_eq!(
batched(&view),
[PathBuf::from("/scan/nx/packages/ui/node_modules")]
);
view.removed(Path::new("/scan/nx/node_modules"), 200, true);
view.animate(start);
assert_eq!(view.marked().claims, 1);
assert_eq!(view.marked().bytes, 100);
}
#[test]
fn a_row_the_deleter_has_touched_cannot_be_marked() {
let mut view = view();
let start = Instant::now();
view.animate(start);
point_at(&mut view, "/scan/old");
view.apply(Action::Expand);
point_at(&mut view, "/scan/old/target");
view.freeing(Path::new("/scan/old/target"), 5);
view.animate(start);
view.apply(Action::Mark);
assert_eq!(view.marked().claims, 0);
assert!(view.batch().is_empty());
}
#[test]
fn the_freed_counter_climbs_on_the_same_bytes_the_reclaimable_one_loses() {
let mut tree = Tree::new("/scan");
tree.insert(hit("/scan/nx/node_modules", Size::Measured(300_000), 900));
tree.insert(hit("/scan/old/target", Size::Measured(100_000), 100));
let mut view = View::new(tree);
view.viewport(40);
let start = Instant::now();
view.animate(start);
assert!(!view.has_freed());
assert_eq!(view.drawn_total().bytes, 400_000);
view.freeing(Path::new("/scan/old/target"), 40_000);
view.animate(start + COUNT_UP * 8);
assert!(view.has_freed());
assert_eq!(view.drawn_total().bytes, 360_000);
assert_eq!(view.drawn_freed(), 40_000);
view.removed(Path::new("/scan/old/target"), 100_000, true);
view.animate(start + COUNT_UP * 16);
assert_eq!(view.drawn_total().bytes, 300_000);
assert_eq!(view.drawn_freed(), 100_000);
}
#[test]
fn the_batch_report_replaces_the_running_total_rather_than_adding_to_it() {
let mut tree = Tree::new("/scan");
tree.insert(hit("/scan/old/target", Size::Measured(100_000), 100));
let mut view = View::new(tree);
view.viewport(40);
let start = Instant::now();
view.animate(start);
view.freeing(Path::new("/scan/old/target"), 60_000);
view.removed(Path::new("/scan/old/target"), 100_000, true);
view.animate(start + COUNT_UP * 8);
assert_eq!(view.drawn_freed(), 100_000);
view.deleted(
Notice::passing("removed 97.7 KiB from 1 directory"),
100_000,
);
view.animate(start + COUNT_UP * 16);
assert_eq!(view.drawn_freed(), 100_000);
view.animate(start + COUNT_UP * 16 + DIM * 2);
assert_eq!(view.drawn_freed(), 100_000);
assert!(view.tree().find(Path::new("/scan/old/target")).is_none());
}
#[test]
fn a_directory_the_safety_model_refused_says_so_on_its_own_row() {
let mut view = view();
view.refused(&[Refused {
path: PathBuf::from("/scan/old/target"),
reason: Refusal::HoldsCheckout,
}]);
assert_eq!(
view.kept_reason(at(&view, "/scan/old/target")),
Some("holds a git checkout")
);
assert_eq!(view.kept_reason(at(&view, "/scan/nx/node_modules")), None);
}
fn tree_with_an_env_file() -> Tree {
let mut tree = Tree::new("/scan");
tree.insert(sized(
of_kind("/scan/nx/node_modules", Kind::Dependencies),
200,
));
tree.insert(sized(
gitignored_file("/scan/nx/app/.env", Some(Kind::Unrecoverable)),
40,
));
tree.insert(sized(
gitignored_file("/scan/nx/app/build.log", Some(Kind::Noise)),
10,
));
tree
}
fn with_an_env_file() -> View {
let mut view = View::new(tree_with_an_env_file());
view.viewport(40);
view.apply(Action::ToggleFiles);
view
}
#[test]
fn a_run_that_asked_for_files_on_the_command_line_opens_showing_them() {
let mut tree = Tree::new("/scan");
tree.insert(sized(
gitignored_file("/scan/nx/app/.env", Some(Kind::Unrecoverable)),
40,
));
let mut shut = View::new(tree);
shut.viewport(40);
assert_eq!(shut.total().claims, 0);
assert_eq!(shut.out_of_view(), 1);
let mut open = View::new({
let mut tree = Tree::new("/scan");
tree.insert(sized(
gitignored_file("/scan/nx/app/.env", Some(Kind::Unrecoverable)),
40,
));
tree
})
.showing_files();
open.viewport(40);
assert_eq!(open.total().claims, 1);
assert_eq!(open.out_of_view(), 0);
}
#[test]
fn a_mark_on_a_parent_takes_every_visible_claim_under_it_precious_ones_included() {
let mut view = with_an_env_file();
point_at(&mut view, "/scan/nx");
view.apply(Action::Mark);
view.sync();
let mut took = batched(&view);
took.sort();
assert_eq!(
took,
[
PathBuf::from("/scan/nx/app/.env"),
PathBuf::from("/scan/nx/app/build.log"),
PathBuf::from("/scan/nx/node_modules"),
]
);
assert_eq!(view.marked().claims, 3);
assert_eq!(view.marked().bytes, 250);
}
#[test]
fn the_lens_a_mark_was_made_through_is_the_only_thing_holding_a_precious_file_back() {
let mut view = View::new(tree_with_an_env_file());
view.viewport(40);
point_at(&mut view, "/scan/nx");
view.apply(Action::Mark);
view.sync();
assert_eq!(batched(&view), [PathBuf::from("/scan/nx/node_modules")]);
view.apply(Action::ToggleFiles);
view.sync();
assert_eq!(
batched(&view),
[PathBuf::from("/scan/nx/node_modules")],
"widening the view changed what an existing mark covers"
);
}
#[test]
fn a_precious_row_marked_on_its_own_is_an_ordinary_row() {
let mut view = with_an_env_file();
point_at(&mut view, "/scan/nx/app/.env");
view.apply(Action::Mark);
view.sync();
assert_eq!(batched(&view), [PathBuf::from("/scan/nx/app/.env")]);
assert_eq!(view.marked().claims, 1);
}
#[test]
fn a_precious_claim_arriving_under_a_mark_later_joins_it_like_any_other() {
let mut view = with_an_env_file();
point_at(&mut view, "/scan/nx");
view.apply(Action::Mark);
view.sync();
let before = view.marked().claims;
view.found(sized(
gitignored_file("/scan/nx/deep/id_rsa", Some(Kind::Unrecoverable)),
8,
));
view.found(sized(of_kind("/scan/nx/deep/dist", Kind::Build), 5));
view.sync();
assert_eq!(view.marked().claims, before + 2);
assert!(batched(&view).contains(&PathBuf::from("/scan/nx/deep/id_rsa")));
}
#[test]
fn an_unrecoverable_entry_is_countable_on_the_confirmation() {
let mut view = with_an_env_file();
view.asking(
&[
Planned::at("/scan/nx/app/.env", Size::Measured(40)),
Planned::at("/scan/nx/node_modules", Size::Measured(200)),
],
&[],
);
let pending = view.pending().expect("the box is up");
assert_eq!(pending.unrecoverable(), 1);
assert_eq!(
pending.entries()[0].path,
PathBuf::from("/scan/nx/app/.env")
);
assert_eq!(pending.entries()[0].kind, Some(Kind::Unrecoverable));
}
#[test]
fn a_refused_unrecoverable_entry_is_not_what_the_warning_is_about() {
let mut view = with_an_env_file();
view.asking(
&[Planned::at("/scan/nx/node_modules", Size::Measured(200))],
&[Refused {
path: PathBuf::from("/scan/nx/app/.env"),
reason: Refusal::HoldsCheckout,
}],
);
assert_eq!(view.pending().expect("the box is up").unrecoverable(), 0);
}
fn batched(view: &View) -> Vec<PathBuf> {
view.batch().into_iter().map(|target| target.path).collect()
}
fn filter(view: &mut View, pattern: &str) {
view.apply(Action::OpenFilter);
for character in pattern.chars() {
view.apply(Action::Type(character));
}
view.apply(Action::Submit);
}
fn planned(targets: &[&str]) -> Vec<Planned> {
targets
.iter()
.map(|path| Planned::at(*path, Size::Measured(10)))
.collect()
}
fn priced(targets: &[(&str, u64)]) -> Vec<Planned> {
targets
.iter()
.map(|(path, bytes)| Planned::at(*path, Size::Measured(*bytes)))
.collect()
}
}
#[cfg(test)]
mod scale {
use crate::fixture::priced;
use crate::tree::{Order, Tree};
use crate::tui::keymap::{Action, Motion, Turn};
use crate::tui::state::View;
use crate::tui::treemap::Maps;
use std::time::Instant;
fn home() -> Tree {
let mut tree = Tree::new("/home");
for n in 0..8_660 {
tree.insert(priced(&format!("/home/types/p{n}/node_modules"), 1024));
}
for repo in 0..300 {
for pkg in 0..20 {
tree.insert(priced(
&format!("/home/repos/r{repo}/packages/p{pkg}/node_modules"),
4096,
));
}
}
for n in 0..1_353 {
tree.insert(priced(&format!("/home/cache/a/b/c/d/e{n}/target"), 512));
}
tree
}
#[test]
#[ignore = "a measurement rather than an assertion; timings are not a pass or a fail"]
fn measure_a_home_directorys_worth_of_rows() {
let tree = home();
println!("nodes: {}, claims: {}", tree.len(), tree.claims());
let started = Instant::now();
let mut view = View::new(tree);
view.viewport(50);
println!("open, collapsed: {:?}", started.elapsed());
let started = Instant::now();
view.apply(Action::Cursor(Motion::Top));
view.apply(Action::ToggleSubtree);
view.apply(Action::ToggleSubtree);
println!(
"expand everything: {:?} -> {} rows",
started.elapsed(),
view.rows().len()
);
let started = Instant::now();
view.apply(Action::SortBy(Order::Path));
println!("re-sort, fully expanded: {:?}", started.elapsed());
let started = Instant::now();
view.found(priced("/home/repos/late/node_modules", 1));
view.sync();
println!("one arrival, fully expanded: {:?}", started.elapsed());
}
#[test]
#[ignore = "a measurement rather than an assertion; timings are not a pass or a fail"]
fn measure_what_one_animated_frame_costs() {
let tree = home();
let mut view = View::new(tree);
view.viewport(50);
view.apply(Action::Cursor(Motion::Top));
view.apply(Action::ToggleSubtree);
view.apply(Action::ToggleSubtree);
let epoch = Instant::now();
println!("rows: {}", view.rows().len());
let started = Instant::now();
for tick in 1..=100 {
view.animate(epoch + super::super::moving::COUNT_UP * tick);
}
println!("quiet frame: {:?}", started.elapsed() / 100);
let started = Instant::now();
for tick in 101..=200u32 {
view.found(priced(&format!("/home/repos/late{tick}/node_modules"), 1));
view.animate(epoch + super::super::moving::COUNT_UP * tick);
}
println!("frame during a scan: {:?}", started.elapsed() / 100);
showing(&mut view, super::Preset::All);
let started = Instant::now();
for tick in 201..=300u32 {
view.found(priced(&format!("/home/repos/wide{tick}/node_modules"), 1));
view.animate(epoch + super::super::moving::COUNT_UP * tick);
}
println!("frame, nothing hidden: {:?}", started.elapsed() / 100);
showing(&mut view, super::Preset::Default);
view.apply(Action::MarkAll);
let started = Instant::now();
for tick in 301..=400u32 {
view.found(priced(&format!("/home/repos/later{tick}/node_modules"), 1));
view.animate(epoch + super::super::moving::COUNT_UP * tick);
}
println!("frame, everything marked: {:?}", started.elapsed() / 100);
select(&mut view, "/home/types/p0/node_modules");
view.apply(Action::Mark);
println!(
"marks and exclusions: {} + {}",
view.marks.len(),
view.spared.len()
);
let started = Instant::now();
for tick in 401..=500u32 {
view.found(priced(&format!("/home/repos/spared{tick}/node_modules"), 1));
view.animate(epoch + super::super::moving::COUNT_UP * tick);
}
println!("frame, one row spared: {:?}", started.elapsed() / 100);
view.apply(Action::ToggleKind(crate::rules::Kind::Dependencies));
let started = Instant::now();
for tick in 501..=600u32 {
view.found(priced(&format!("/home/repos/lens{tick}/node_modules"), 1));
view.animate(epoch + super::super::moving::COUNT_UP * tick);
}
println!("frame, marked and narrowed: {:?}", started.elapsed() / 100);
view.allow_maps(Maps::Can);
let started = Instant::now();
for tick in 601..=700u32 {
view.found(priced(&format!("/home/repos/map{tick}/node_modules"), 1));
view.animate(epoch + super::super::moving::COUNT_UP * tick);
}
println!("…the same, with a map up: {:?}", started.elapsed() / 100);
}
fn showing(view: &mut View, preset: super::Preset) {
for _ in 0..super::Preset::ALL.len() {
if view.preset() == Some(preset) {
return;
}
view.apply(Action::CyclePreset(Turn::Next));
}
panic!("{preset} is not on the cycle");
}
fn select(view: &mut View, path: &str) {
let want = std::path::PathBuf::from(path);
let at = view
.rows()
.iter()
.position(|row| view.tree().node(row.id).path == want)
.expect("the fixture is fully expanded");
view.apply(Action::Cursor(Motion::Top));
for _ in 0..at {
view.apply(Action::Cursor(Motion::Down));
}
}
}