Skip to main content

pristine/tui/
state.rs

1//! The live view's whole state: what is open, what is marked, and where the cursor is.
2//!
3//! Nothing here touches the terminal or the filesystem. Every rule the front end has is a
4//! function of a [`Tree`] and some keystrokes, which is what makes them assertable one at a
5//! time — "marking a collapsed row marks everything under it" is a test rather than a
6//! screenshot.
7//!
8//! # The cursor is anchored to a path, never to a row
9//!
10//! pua anchors its cursor to `(pid, start_time)` because processes vanish under a live cursor.
11//! Here the equivalent event is **deletion**: rows disappear as the deleter finishes each
12//! target, and a cursor holding a row *index* would slide onto whatever fell into that
13//! position — which, under a held key, deletes something nobody chose.
14//!
15//! So a keystroke or an arrival re-walks the tree and puts the cursor back on the **path** it
16//! was on. If that path is gone the chain of its ancestors is tried in turn, which lands on the
17//! nearest surviving directory rather than on the other side of the screen — usually the
18//! project whose `node_modules` was just deleted, and at worst the scan root, which is the last
19//! rung of the chain and still somewhere the reader was. If none of them is on screen at all —
20//! which a filter can do, where a deletion cannot — the cursor is **deselected**, visibly, so
21//! the next arrow key picks a row deliberately.
22//!
23//! There is no index fallback, and that is the point rather than an omission: clamping the old
24//! index is the same mis-selection one step removed. The two outcomes above are both statements
25//! about a *directory*; row 0 is a statement about a position, and after a deletion the two
26//! name different things.
27//!
28//! # Marks are subtree roots, not a set of rows
29//!
30//! Marking a collapsed row has to mark everything beneath it — that is the whole reason the
31//! rollup is worth having — and the tree it covers can still be *growing* while the scan runs.
32//! Storing the covered claims would mean a set of 8,660 ids for one keystroke and a set that
33//! silently missed whatever arrived afterwards. So a mark is the **root of a marked subtree**,
34//! a row is marked when it or any ancestor is one, and a row is partial when a mark sits
35//! somewhere below it. A claim that streams in under a marked directory is marked on arrival,
36//! which is what a reader who marked that directory asked for.
37//!
38//! Unmarking one row out of a marked subtree is the interesting case, and it is why the marks
39//! are not just a set: the ancestor's mark is **pushed down**, replaced by marks on everything
40//! beside the path to the row being spared. "Mark all, then keep this one" is a real workflow —
41//! npkill's select-all exists for the first half of it — and the alternative is telling a
42//! reader to clear forty marks and start again.
43//!
44//! # What the footer says is transient, and has to be able to stop being said
45//!
46//! A [`Notice`] is a report about something that has already happened, drawn in permanent
47//! furniture: the footer, which otherwise carries the keys. So a report with no way out is a
48//! stale claim sitting on the one line that tells a reader what they can do — and the older it
49//! gets the less of the tree it still describes. See [`Notice`] for how long one lasts and why.
50//!
51//! # The clock is handed in, like everything else
52//!
53//! This file animates ([`super::moving`]) and still has no terminal and no filesystem in it,
54//! because time arrives the same way a keystroke does: [`View::animate`] is given the instant
55//! and everything else reads it off [`View`]. So "a removed row empties for a third of a
56//! second and then collapses away" is an assertion with three `advance`s in it rather than a
57//! test that sleeps, and the drain's *consequences* — a row that can no longer be marked,
58//! deleted a second time, or counted into a batch — are assertions too.
59//!
60//! The one thing on screen that is deliberately **not** on that clock is the notice. Everything
61//! the clock drives is a number moving towards a fact the reader can still go and check; a
62//! report of what was destroyed is the one thing they cannot, so its lifetime is a reader's
63//! action instead. See [`Notice`].
64
65use std::collections::{HashMap, HashSet};
66use std::path::{Path, PathBuf};
67use std::time::Instant;
68
69use regex::Regex;
70
71use super::keymap::{Action, Motion, Overlay, Turn};
72use super::lens::{Lens, Preset};
73use super::moving::Moving;
74use super::treemap::Maps;
75use crate::delete::{Plan, Refused, Target};
76use crate::rules::Kind;
77use crate::size::{Size, human};
78use crate::tree::{NodeId, Order, Sort, Tree};
79use crate::walk::Hit;
80
81/// Rows one turn of the wheel moves the viewport.
82///
83/// Three rather than one because a wheel notch that moved a single row reads as a tool that
84/// is not answering, and rather than a page because a page is what `Ctrl-d` is for: the wheel
85/// is how a reader looks around without losing their place.
86const WHEEL: usize = 3;
87
88/// One line of the tree, once the collapsed subtrees have been left out.
89#[derive(Clone, Copy, Debug, PartialEq, Eq)]
90pub struct Row {
91    /// Which directory.
92    pub id: NodeId,
93    /// How deep, for the indent. The root is 0.
94    pub depth: usize,
95}
96
97/// How much of a row's subtree is marked.
98#[derive(Clone, Copy, Debug, PartialEq, Eq)]
99pub enum Mark {
100    /// Nothing under here.
101    None,
102    /// Some of it — the state an ancestor shows when only part of it is spoken for.
103    Partial,
104    /// This row and everything beneath it.
105    All,
106}
107
108/// What a row's subtree is worth: the three numbers every total on the screen is made of.
109///
110/// Carried together because a filter has to be able to answer all three about *what it shows*
111/// rather than about what is there, and answering two of them consistently is not enough — a
112/// selection stated in bytes that came from one set and directories that came from another is
113/// the arithmetic a reader would catch first.
114#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
115pub struct Roll {
116    /// Measured reclaimable bytes.
117    pub bytes: u64,
118    /// Claims, priced or not.
119    pub claims: usize,
120    /// How many of those claims nobody has put a number on.
121    pub unpriced: usize,
122}
123
124impl Roll {
125    /// What the size column shows: a number, or a dash when there is nothing to add up yet.
126    ///
127    /// The distinction the performance thesis forces onto the front end. A default scan
128    /// prices 8% of what it finds, so `0` and "not looked" are wildly different facts about a
129    /// row and rendering both as `0 B` would report a 40 GiB `node_modules` as empty.
130    ///
131    /// There is a **third** state between those two, and it is the ordinary one for an
132    /// ancestor while the pool works: some of what is under here is priced and some is not.
133    /// `4.2 GiB` for a row that is really worth 4.9 GiB is wrong in the direction a cleaner
134    /// must not be wrong in, so it is spelled `> 4.2 GiB`. The `>` is true the whole time it
135    /// is up, it costs nothing, and it is what turns the number climbing underneath it into
136    /// information rather than a number that cannot make its mind up.
137    #[must_use]
138    pub fn label(&self) -> String {
139        match (self.bytes, self.unpriced) {
140            (0, 1..) => Size::Unmeasured.label(),
141            (bytes, 1..) => format!("> {}", human(bytes)),
142            (bytes, 0) => human(bytes),
143        }
144    }
145}
146
147/// How far through its batch a running removal is.
148///
149/// A running byte total is the wrong thing on its own, and real use is what showed it: bytes
150/// say how much has gone but not how much is left to go, so a reader watching a long delete
151/// cannot tell a third of the way through from nearly finished. A **count of targets against
152/// the batch's own total** can, and this is the one phase of the run where the denominator is
153/// honest without qualification — it is fixed the instant the reader answers the confirmation,
154/// where the pricing bar's denominator grows as the walk finds claims faster than the pool can
155/// price them.
156///
157/// It is a lower bound, and deliberately so. The deleter speaks only for a target something
158/// actually *happened* to, so a batch where one turned out to be gone already ends at eleven of
159/// twelve rather than counting a directory nobody touched. The state ends when the batch
160/// reports, not when the count reaches its total.
161///
162/// # …and a count on its own is not enough either, which took a real batch to learn
163///
164/// The paragraph above is right that bytes cannot say how much is left. What it missed is that
165/// a count cannot say how much is left *either*, because targets are not the same size — and
166/// they are not close. A real `pristine ~` batch of 2,188 directories sat at **2,162 of 2,188,
167/// 98%** for over an hour, because the small ones drain first and the twenty-six still going
168/// were most of the bytes. Every figure on the screen was true and the reader still could not
169/// tell it from a hang.
170///
171/// So there are two, and they answer the two different questions a reader has: [`percent`] is
172/// how far through the *list*, [`weighed`] is how much of the *weight*, and [`busiest`] names
173/// the one target that decides when it ends. Neither number is the other's approximation.
174///
175/// [`percent`]: Removing::percent
176/// [`weighed`]: Removing::weighed
177/// [`busiest`]: Removing::busiest
178#[derive(Clone, Debug, PartialEq, Eq)]
179pub struct Removing {
180    /// Every target the confirmed plan handed to the deleter, and where each has got to.
181    targets: HashMap<PathBuf, Live>,
182    /// Targets the confirmed plan handed to the deleter. Held rather than counted off the map
183    /// above, which collapses a plan that named one target twice — and a denominator that
184    /// quietly shrank would make the batch smaller than the dialog promised.
185    total: usize,
186    /// Targets the deleter has reported finishing with, whole or in part.
187    done: usize,
188    /// What the plan said the whole batch was worth, which is only the part anybody had priced.
189    /// Zero when none of it was, which is the state a default scan leaves most batches in.
190    planned: u64,
191}
192
193/// Where one target of a batch has got to.
194#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
195struct Live {
196    /// What the plan thought this target was worth, or zero when nothing had priced it.
197    planned: u64,
198    /// The latest cumulative figure it has reported. Assigned rather than added to, because
199    /// [`crate::Freeing::bytes`] is a running total and never a delta — which is what makes a
200    /// consumer that misses a report or coalesces two of them exact anyway.
201    freed: u64,
202    /// Whether the pool has moved off it.
203    swept: bool,
204}
205
206impl Removing {
207    /// The start of a batch, given what the plan thought each of its targets was worth.
208    fn new(targets: &[(PathBuf, u64)]) -> Self {
209        Self {
210            total: targets.len(),
211            done: 0,
212            planned: targets.iter().map(|(_, planned)| planned).sum(),
213            targets: targets
214                .iter()
215                .map(|(path, planned)| {
216                    let live = Live {
217                        planned: *planned,
218                        ..Live::default()
219                    };
220                    (path.clone(), live)
221                })
222                .collect(),
223        }
224    }
225
226    /// Notes one more target the deleter has come back out of.
227    ///
228    /// Capped at the total rather than allowed past it: the count is a position in a batch of
229    /// known size, and a `13 of 12` would say the batch was not what the confirmation said it
230    /// was — which is the one thing the dialog promises.
231    ///
232    /// The count moves whether or not `path` is one this batch knows about. That is deliberate
233    /// and it is load-bearing: the position is the one figure here that needs no path to be
234    /// right, so it stays right even if every path-keyed thing beside it stops matching.
235    fn finished(&mut self, path: &Path) {
236        self.done = self.done.saturating_add(1).min(self.total);
237        if let Some(live) = self.targets.get_mut(path) {
238            live.swept = true;
239        }
240    }
241
242    /// Bytes one target has given back so far, as a running total.
243    fn freeing(&mut self, path: &Path, bytes: u64) {
244        if let Some(live) = self.targets.get_mut(path) {
245            live.freed = bytes;
246        }
247    }
248
249    /// Targets done, and how many there are.
250    #[must_use]
251    pub fn counted(&self) -> (usize, usize) {
252        (self.done, self.total)
253    }
254
255    /// How far through, for the footer and for the dock.
256    ///
257    /// **Targets rather than bytes, and that is not an oversight.** A batch that failed on every
258    /// one of its targets has still been worked through, and a bar weighted by bytes would read
259    /// 0% for the whole of it — which reports the *outcome* under the guise of the position.
260    /// What bytes are good for is saying how much is left, and [`Removing::weighed`] says that
261    /// beside this rather than instead of it.
262    #[must_use]
263    pub fn percent(&self) -> u8 {
264        percent(self.done, self.total)
265    }
266
267    /// Bytes given back so far against what the plan expected of the whole batch, or `None`
268    /// when nothing in it was priced and there is no denominator to give.
269    ///
270    /// This is the half of the answer a count cannot give. Targets vary in size by four orders
271    /// of magnitude, so "2162 of 2188" says nothing about whether the remainder is a second or
272    /// an hour — and the last few targets of a real batch are routinely most of its bytes.
273    #[must_use]
274    pub fn weighed(&self) -> Option<(u64, u64)> {
275        (self.planned > 0).then(|| (self.freed(), self.planned))
276    }
277
278    /// Bytes the batch has given back so far, across every target in it.
279    #[must_use]
280    pub fn freed(&self) -> u64 {
281        self.targets.values().map(|live| live.freed).sum()
282    }
283
284    /// The target the batch is most likely to be waiting on: the largest one the pool has
285    /// started and not yet moved off.
286    ///
287    /// A removal runs its targets concurrently, so there is no single current one — but there
288    /// is one that decides when the batch ends. A target is swept by a single thread, so once
289    /// the pool has more threads than targets left the finish time is the largest survivor's,
290    /// and that is the name worth drawing. It changes only when that target is done, where
291    /// naming the most recent report would flicker between unrelated paths several times a
292    /// second — motion that is not information.
293    ///
294    /// Weighed by what the plan thought each was worth, falling back to what each has already
295    /// given back when nothing priced them: on an unpriced batch the target that has freed the
296    /// most is the best available guess at the biggest. The path breaks the remaining ties, so
297    /// that two equal targets do not swap the name between frames.
298    #[must_use]
299    pub fn busiest(&self) -> Option<&Path> {
300        self.targets
301            .iter()
302            .filter(|(_, live)| !live.swept && live.freed > 0)
303            .max_by_key(|(path, live)| (live.planned, live.freed, *path))
304            .map(|(path, _)| path.as_path())
305    }
306
307    /// What the footer says: where the deleter is, and how much of the batch's weight that
308    /// leaves. The name of what it is working on is drawn beside this rather than folded in,
309    /// because only the renderer knows how much room is left for a path.
310    #[must_use]
311    pub fn label(&self) -> String {
312        let weight = match self.weighed() {
313            Some((freed, planned)) => format!(" · {} of {}", human(freed), human(planned)),
314            None => String::new(),
315        };
316        format!(
317            "removing {} of {} · {}%{weight}",
318            self.done,
319            plural(self.total, "directory", "directories"),
320            self.percent()
321        )
322    }
323}
324
325/// `part` of `whole` as a percentage, saturating rather than wrapping.
326///
327/// Lives here rather than beside its other caller in [`super::chrome`] so that the dependency
328/// runs the way the layering does: the chrome reads the view, and the view knows nothing about
329/// a terminal. One implementation, because a footer and a taskbar bar that rounded differently
330/// would be two claims about the same run.
331pub(super) fn percent(part: usize, whole: usize) -> u8 {
332    if whole == 0 {
333        return 0;
334    }
335    let scaled = part.saturating_mul(100) / whole;
336    u8::try_from(scaled.min(100)).unwrap_or(100)
337}
338
339/// One directory a resolved plan is going to remove, as the confirmation needs it.
340///
341/// The half of a [`crate::delete::PlanTarget`] this screen reads, restated so the screen can
342/// be driven without a filesystem: both spellings of the path, and what the scan priced it at.
343#[derive(Clone, Debug, PartialEq, Eq)]
344pub struct Planned {
345    /// The path as the scan spelled it, which is what the tree holds.
346    pub requested: PathBuf,
347    /// The path the deleter will unlink.
348    pub resolved: PathBuf,
349    /// What the scan knew about its size.
350    pub size: Size,
351}
352
353impl Planned {
354    /// A target whose two spellings are the same, which is every target outside a symlinked
355    /// ancestor.
356    #[must_use]
357    pub fn at(path: impl Into<PathBuf>, size: Size) -> Self {
358        let path = path.into();
359        Self {
360            requested: path.clone(),
361            resolved: path,
362            size,
363        }
364    }
365}
366
367/// One line of the batch a confirmation lists.
368///
369/// Everything a reader needs in order to recognise a directory they marked several views ago:
370/// where it is, what it is, what it is worth, whether they can currently *see* it, and whether
371/// the safety model is going to refuse it anyway.
372#[derive(Clone, Debug, PartialEq, Eq)]
373pub struct Entry {
374    /// Which directory, by identity. What `space` on this line acts on, for the reason every
375    /// other action in this file names a [`NodeId`]: the tree moves while a dialog is up.
376    ///
377    /// `None` for a path the tree no longer holds, which is a line that can be read and not
378    /// unmarked — the honest state rather than a line that quietly does nothing.
379    pub id: Option<NodeId>,
380    /// Where it is, spelled as the scan spelled it — which is what the tree holds and what a
381    /// reader recognises.
382    pub path: PathBuf,
383    /// The **resolved** path the deleter would unlink, when this line is going to be removed
384    /// at all. `None` on a line the safety model refused.
385    ///
386    /// Two paths rather than one because the planner resolves `..` and symlinked ancestors,
387    /// and on macOS that is not exotic: a scan of `/var/…` plans against `/private/var/…`.
388    /// Taking a line out of the batch has to name the same path the deed does, or the deed
389    /// would keep a directory the listing had stopped showing.
390    pub target: Option<PathBuf>,
391    /// What it is, which is also what the listing groups by. `None` is the tier-two claim's
392    /// own content rather than a gap: nothing named it.
393    pub kind: Option<Kind>,
394    /// The ecosystem and the kind, as a row of the tree would say it.
395    pub label: String,
396    /// What the scan priced it at, which on a default scan is nothing.
397    pub size: Size,
398    /// Whether the **current** view is hiding it. The point of the screen: a reader marks
399    /// broadly under one view, narrows, forgets, and is about to confirm a deletion whose
400    /// contents they cannot see.
401    pub hidden: bool,
402    /// Why the safety model is going to leave it standing, if it is.
403    ///
404    /// Said *here*, before the reader commits, rather than in the post-run report — which is
405    /// the same refusal reporting, moved to the moment it can still change a decision.
406    pub kept: Option<String>,
407}
408
409/// The question the delete key asks, and everything it is holding while it asks.
410///
411/// It carries the **targets the plan resolved**, not "whatever is marked when the answer is
412/// taken". The tree moves while a dialog is up — claims arrive, prices land, an earlier
413/// deletion finishes — and a deed that re-read the marks at the moment of the answer would
414/// remove a different set from the one the box described.
415///
416/// # It lists the batch, and that is the safety half of orthogonal selection
417///
418/// A selection that is independent of what is visible creates a hazard that did not exist when
419/// the two were the same thing, and the mitigation is that the box **shows what it is holding**
420/// — grouped by kind, with the hidden entries named as hidden and every one of them
421/// unmarkable from here. The answer to a surprise has to be better than "cancel and start
422/// again".
423#[derive(Clone, Debug, PartialEq, Eq)]
424pub struct Pending {
425    /// Exactly what will be removed.
426    pub targets: Vec<PathBuf>,
427    /// Every directory the batch touched, refused ones included — what the listing draws.
428    pub entries: Vec<Entry>,
429    /// What the plan says that is worth, which is only the part anybody has priced.
430    pub bytes: u64,
431    /// How many of the targets carry no price.
432    pub unpriced: usize,
433    /// How the view that is hiding some of this spells itself, so the warning can name it.
434    pub view: String,
435    /// Which line the reader is on.
436    at: usize,
437    /// The first line drawn. Held here rather than derived, so a listing does not jump under
438    /// a reader moving back up it.
439    scroll: usize,
440    /// How many lines the box has room for. The renderer owns the number and tells the view,
441    /// exactly as it does for the tree's own viewport.
442    page: usize,
443    /// Which answer is highlighted. Starts on cancel — the key a reader presses to get rid of
444    /// what is in front of them has to be the safe one.
445    pub answer: Answer,
446}
447
448impl Pending {
449    /// The lines, in the order they are drawn: grouped by kind, and by path inside a group.
450    #[must_use]
451    pub fn entries(&self) -> &[Entry] {
452        &self.entries
453    }
454
455    /// Which line the cursor is on.
456    #[must_use]
457    pub fn at(&self) -> usize {
458        self.at
459    }
460
461    /// The first line drawn.
462    #[must_use]
463    pub fn scroll(&self) -> usize {
464        self.scroll
465    }
466
467    /// How many lines the box has room for.
468    #[must_use]
469    pub fn page(&self) -> usize {
470        self.page
471    }
472
473    /// How many of the entries the current view is hiding.
474    #[must_use]
475    pub fn hidden(&self) -> usize {
476        self.entries.iter().filter(|entry| entry.hidden).count()
477    }
478
479    /// How many lines of this batch are things nothing brings back.
480    ///
481    /// Counted over the lines that are actually going to be removed, refusals excluded: a
482    /// directory the safety model is leaving standing is not one this warning is about, and
483    /// counting it would put a red line over a batch that takes nothing precious.
484    #[must_use]
485    pub fn unrecoverable(&self) -> usize {
486        self.entries
487            .iter()
488            .filter(|entry| entry.kept.is_none() && entry.kind == Some(Kind::Unrecoverable))
489            .count()
490    }
491
492    /// How many the safety model will leave standing.
493    #[must_use]
494    pub fn kept(&self) -> usize {
495        self.entries
496            .iter()
497            .filter(|entry| entry.kept.is_some())
498            .count()
499    }
500
501    /// The line under the cursor.
502    fn current(&self) -> Option<&Entry> {
503        self.entries.get(self.at)
504    }
505
506    /// Moves the cursor, and the listing under it.
507    fn walk(&mut self, motion: Motion) {
508        let Some(last) = self.entries.len().checked_sub(1) else {
509            return;
510        };
511        let page = self.page.max(1);
512        self.at = match motion {
513            Motion::Up => self.at.saturating_sub(1),
514            Motion::Down => (self.at + 1).min(last),
515            Motion::PageUp => self.at.saturating_sub(page),
516            Motion::PageDown => (self.at + page).min(last),
517            Motion::Top => 0,
518            Motion::Bottom => last,
519        };
520        self.follow();
521    }
522
523    /// Keeps the drawn window over the cursor, and inside the entries either way.
524    fn follow(&mut self) {
525        let page = self.page.max(1);
526        if self.at < self.scroll {
527            self.scroll = self.at;
528        } else if self.at >= self.scroll + page {
529            self.scroll = self.at + 1 - page;
530        }
531        self.scroll = self.scroll.min(self.entries.len().saturating_sub(1));
532    }
533
534    /// Takes one line out of the batch: the deed shrinks with the listing, because a dialog
535    /// that showed one thing and removed another would be the failure this type exists to
536    /// prevent.
537    fn drop_at(&mut self, at: usize) -> Option<Entry> {
538        if at >= self.entries.len() {
539            return None;
540        }
541        let entry = self.entries.remove(at);
542        // Matched on the requested path because that is what the deed carries — see
543        // [`Pending::targets`]. `target` is still what says whether this line is a target at
544        // all, which a refusal is not.
545        if entry.target.is_some() {
546            self.targets.retain(|path| path != &entry.path);
547        }
548        self.bytes = self.bytes.saturating_sub(entry.size.bytes().unwrap_or(0));
549        if entry.kept.is_none() && entry.size.bytes().is_none() {
550            self.unpriced = self.unpriced.saturating_sub(1);
551        }
552        self.at = self.at.min(self.entries.len().saturating_sub(1));
553        self.follow();
554        Some(entry)
555    }
556}
557
558/// The two answers a confirmation has.
559#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
560pub enum Answer {
561    /// Leave everything where it is. Where the highlight starts, always.
562    #[default]
563    Cancel,
564    /// Go ahead.
565    Delete,
566}
567
568impl Answer {
569    /// Both answers, in the order the box draws them — safe one first, which is also the
570    /// order the arrow keys move through and the order a click is resolved against.
571    pub const ALL: [Self; 2] = [Self::Cancel, Self::Delete];
572
573    /// The word on the button.
574    #[must_use]
575    pub fn label(self) -> &'static str {
576        match self {
577            Self::Cancel => "cancel",
578            Self::Delete => "delete",
579        }
580    }
581
582    /// Which way the highlight has to move to land on this answer.
583    ///
584    /// The pointer aims by naming an answer and the keyboard aims by turning; this is the one
585    /// place the two are reconciled, so a hover and a `→` cannot end up meaning different
586    /// things.
587    #[must_use]
588    pub fn turn(self) -> Turn {
589        match self {
590            Self::Cancel => Turn::Prev,
591            Self::Delete => Turn::Next,
592        }
593    }
594}
595
596/// The filter prompt, while it is up.
597///
598/// Separate from the applied filter because they are different facts: what is being typed and
599/// what is being shown. A prompt that wrote straight through would re-walk the tree on every
600/// keystroke of a pattern that is not finished, and `Esc` would have nothing to restore.
601#[derive(Clone, Debug, Default, PartialEq, Eq)]
602pub struct Prompt {
603    /// Characters rather than bytes, because the caret moves by character and a path is
604    /// arbitrary Unicode.
605    chars: Vec<char>,
606    caret: usize,
607    /// What the regex engine said about the last thing submitted, if it said no.
608    error: Option<String>,
609}
610
611impl Prompt {
612    /// A prompt holding `seed`, with the caret at the end.
613    fn seeded(seed: &str) -> Self {
614        let chars: Vec<char> = seed.chars().collect();
615        Self {
616            caret: chars.len(),
617            chars,
618            error: None,
619        }
620    }
621
622    /// What has been typed.
623    #[must_use]
624    pub fn text(&self) -> String {
625        self.chars.iter().collect()
626    }
627
628    /// Which character the caret is before.
629    #[must_use]
630    pub fn caret(&self) -> usize {
631        self.caret
632    }
633
634    /// Why the last pattern was refused, if it was.
635    #[must_use]
636    pub fn error(&self) -> Option<&str> {
637        self.error.as_deref()
638    }
639}
640
641/// One mark: a directory, and the view the reader was looking through when they made it.
642///
643/// **A mark cannot be stored as "the subtree under N", and that is the load-bearing
644/// constraint.** Toggling what is visible must never change what is selected, so if a mark
645/// were only a node, re-deriving what it covers under a different view would silently change
646/// the batch — which is precisely the behaviour being ruled out. Baking the lens into the mark
647/// is what makes switching views *inert*.
648///
649/// It is a pair resolved on demand rather than a frozen list of ids for a reason specific to
650/// this tool: **results stream in**. A subtree marked at seven seconds would otherwise never
651/// include the claims that arrive at forty, and "mark this directory" plainly means the
652/// directory rather than the eleven things anybody had found under it so far.
653#[derive(Clone, Debug, PartialEq, Eq)]
654struct Marked {
655    /// Which directory. A [`NodeId`], never a row index — rows re-sort as prices land and
656    /// vanish as removals complete, so an index taken now and acted on later names a
657    /// different directory.
658    root: NodeId,
659    /// What "everything under it" meant when the reader said it.
660    lens: Lens,
661}
662
663/// What one node is worth, three ways.
664///
665/// Carried together because they are three answers to one traversal and because two of them
666/// disagreeing is the arithmetic a reader would catch first. The pair that has to be allowed
667/// to differ is [`visible`](Self::visible) against [`all`](Self::all): a selection made
668/// through one view and read under another is exactly the hazard the confirmation exists to
669/// mitigate, and hiding it by making the batch filter-relative would contradict "retained".
670#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
671struct Counts {
672    /// Claims under this node that survive the current lens — what its row draws.
673    visible: Roll,
674    /// Of those, the ones the marks select. What the mark glyph is computed from, so the
675    /// glyph describes **what the reader can see** rather than a global fact that would
676    /// contradict it.
677    chosen: Roll,
678    /// Everything the marks select under this node, visible or not: the batch, and the number
679    /// the footer states.
680    all: Roll,
681}
682
683impl Counts {
684    /// Folds a child in.
685    fn absorb(&mut self, child: Self) {
686        add(&mut self.visible, child.visible);
687        add(&mut self.chosen, child.chosen);
688        add(&mut self.all, child.all);
689    }
690}
691
692/// Adds one roll into another.
693fn add(into: &mut Roll, roll: Roll) {
694    into.bytes += roll.bytes;
695    into.claims += roll.claims;
696    into.unpriced += roll.unpriced;
697}
698
699/// A node [`tally`] is holding a mark for.
700const MARKED: u8 = 1;
701/// A node the reader has individually spared.
702const SPARED: u8 = 2;
703
704/// A step of the one traversal that answers everything the marks and the lens decide.
705enum Step {
706    /// On the way down, carrying how deep this node is.
707    Enter(NodeId, usize),
708    /// On the way back up, where the children's numbers are already in.
709    Leave(NodeId, usize),
710}
711
712/// Walks the whole tree once, saying what each node is worth and which claims are selected.
713///
714/// # Why one pass rather than three
715///
716/// The rolled-up numbers are recomputed rather than read off the tree, and that is a **safety**
717/// property before it is a cosmetic one: a row showing 312 GiB while the view hides all but 2
718/// GiB of it is a row whose glyph would claim to describe something it does not. Whole, on
719/// every change, rather than incrementally — the scan streams claims into arbitrary places in
720/// the tree, so an incremental update would have to be right about every arrival, every price
721/// and every deletion, which is three chances to leave a stale number on a row that a mark then
722/// acts on.
723///
724/// The selection is folded into the same pass rather than derived afterwards, because the
725/// counter and the batch **must** be the same set. Two traversals that could disagree is the
726/// bug this file already refuses everywhere else.
727///
728/// # Which marks cover a claim, and which spare it
729///
730/// Both are carried down the path rather than looked up per claim, so this stays O(the tree)
731/// with a handful of live entries rather than O(claims × marks × depth). They are kept with
732/// their **depths** because the two interleave: a reader can mark `~/repos`, spare
733/// `~/repos/a`, and then mark `~/repos/a/b` again — and the deepest thing on the path is the
734/// one that speaks. A shallower mark cannot reach back through a spare below it.
735fn tally(
736    tree: &Tree,
737    lens: &Lens,
738    marks: &[Marked],
739    spared: &HashSet<NodeId>,
740    moving: &Moving,
741    out: &mut Tallied,
742) {
743    let Tallied {
744        counts,
745        selection,
746        map_stamps: stamps,
747    } = out;
748    // Indexed by [`NodeId`] rather than hashed on it, and that is the difference between a
749    // fifth of a frame and a whole one: this runs over 32,634 nodes and the ids are dense —
750    // the tree only ever pushes a slot and never recycles a detached one. A `HashMap` here
751    // costs four `SipHash`es per node for what an index answers for nothing.
752    counts.clear();
753    counts.resize(tree.minted(), Counts::default());
754    selection.clear();
755    // One byte per node saying whether anything at all happens here, so the O(marks) scan that
756    // finds *which* mark only runs on the handful of nodes that carry one.
757    let mut flags = vec![0u8; tree.minted()];
758    for mark in marks {
759        flags[mark.root] |= MARKED;
760    }
761    for &id in spared {
762        flags[id] |= SPARED;
763    }
764    let mut covering: Vec<(usize, &Lens)> = Vec::new();
765    let mut sparing: Vec<usize> = Vec::new();
766    // An explicit stack rather than recursion: the depth here is the filesystem's, and nothing
767    // stops a checkout from being nested far deeper than the ten levels a real home directory
768    // reaches.
769    let mut stack = vec![Step::Enter(tree.root(), 0)];
770    while let Some(step) = stack.pop() {
771        match step {
772            Step::Enter(id, depth) => {
773                if flags[id] & MARKED != 0 {
774                    covering.extend(
775                        marks
776                            .iter()
777                            .filter(|mark| mark.root == id)
778                            .map(|mark| (depth, &mark.lens)),
779                    );
780                }
781                if flags[id] & SPARED != 0 {
782                    sparing.push(depth);
783                }
784                stack.push(Step::Leave(id, depth));
785                for &child in &tree.node(id).children {
786                    stack.push(Step::Enter(child, depth + 1));
787                }
788            }
789            Step::Leave(id, depth) => {
790                let node = tree.node(id);
791                let mut here = Counts::default();
792                // The stamps of the children this node's rectangles are divided among, added
793                // rather than chained: [`Tree::sort_by`] moves children about and the map
794                // orders its own rectangles by weight, so a fold that could see sibling order
795                // would redraw a megabyte on `s` to show the same picture.
796                let mut beneath = 0u64;
797                if let Some(hit) = &node.hit {
798                    let roll = Roll {
799                        bytes: node.reclaimable,
800                        claims: 1,
801                        unpriced: node.unmeasured,
802                    };
803                    let seen = lens.matches(hit);
804                    if seen {
805                        here.visible = roll;
806                    }
807                    let deepest = sparing.last().copied();
808                    // **A mark is a statement about a subtree, and it has no exceptions.**
809                    // Every claim under it that the mark's own lens accepts is covered,
810                    // whatever kind it is. An earlier pass excepted [`Kind::Unrecoverable`]
811                    // unless the mark sat at the claim's exact depth, and that was wrong twice:
812                    // the fractional glyph on an ancestor reads as the share of the subtree
813                    // that is spoken for, so a mark quietly skipping descendants makes it
814                    // describe a set nobody can see — and the exception had no spelling
815                    // anywhere a reader could find it.
816                    //
817                    // What keeps something precious out of a bulk mark is upstream of here and
818                    // needs nothing added: a mark carries the lens it was made through, and no
819                    // lens shows gitignored files until `i` says so. Seeing one at all is the
820                    // deliberate act; after that it is a row like any other.
821                    let chosen = covering.iter().any(|&(at, mark)| {
822                        deepest.is_none_or(|spared| at > spared) && mark.matches(hit)
823                    });
824                    // The deleter's two phases come off the selection at different moments,
825                    // and both timings are load-bearing. A target part way through is a
826                    // directory that **still exists**: its bytes have gone, so the counter
827                    // says so, but "how many directories" must not drop until the sweep says
828                    // it has finished — otherwise the footer reports a directory deleted
829                    // while it is being deleted. It is out of the *batch* from the first byte,
830                    // though: offering a directory that is already going to a second removal
831                    // would report a failure for the one thing that worked.
832                    if chosen && !moving.is_spent(id) {
833                        let counted = Roll {
834                            bytes: roll.bytes.saturating_sub(moving.freed_from(id)),
835                            ..roll
836                        };
837                        here.all = counted;
838                        if seen {
839                            here.chosen = counted;
840                        }
841                        if !moving.is_leaving(id) {
842                            selection.push(id);
843                        }
844                    }
845                } else {
846                    for &child in &node.children {
847                        here.absorb(counts[child]);
848                        // Only the children the map can see. A claim the lens hides is not a
849                        // rectangle, so a claim of that kind arriving must not read as the
850                        // picture having changed — which is the whole reason this is folded
851                        // here, on the lens-aware pass, rather than read off the tree.
852                        if !stamps.is_empty() && counts[child].visible.claims > 0 {
853                            beneath = beneath.wrapping_add(stamps[child]);
854                        }
855                    }
856                }
857                counts[id] = here;
858                if !stamps.is_empty() {
859                    stamps[id] = stamp_of(id, here, beneath);
860                }
861                if flags[id] & MARKED != 0 {
862                    covering.retain(|&(at, _)| at != depth);
863                }
864                if flags[id] & SPARED != 0 {
865                    sparing.pop();
866                }
867            }
868        }
869    }
870}
871
872/// What one pass of [`tally`] writes: three answers to one traversal of the tree.
873///
874/// Carried together because they are read together and because two of them from different
875/// passes would describe two different trees — the same reason [`Counts`] is one struct rather
876/// than three parallel numbers.
877#[derive(Debug, Default)]
878struct Tallied {
879    /// What every node is worth under the current view, and what of that the marks select.
880    counts: Vec<Counts>,
881    /// Every claim the marks select, whichever view each was marked through.
882    selection: Vec<NodeId>,
883    /// See [`View::map_stamp`]. Left empty when nothing is drawing a map, which is how the
884    /// pass is told not to fold one.
885    map_stamps: Vec<u64>,
886}
887
888/// One node's contribution to [`View::map_stamp`]: everything [`super::treemap`] reads about
889/// it, and the stamps of the children it divides its rectangle among.
890///
891/// Exactly what the map reads and nothing else. [`Counts::visible`] is what `roll` answers, so
892/// it is every rectangle's area and every label; [`Counts::chosen`] is what `mark_of` compares,
893/// so it is the colour. [`Counts::all`] is deliberately absent — it is the batch, which the map
894/// never draws, and folding it in would redraw the picture when a claim the lens hides was
895/// selected under a mark.
896///
897/// The `id` goes in because a rollup can be identical across a change that swapped which
898/// directory it came from: a claim arriving as another is deleted puts bytes, claims and
899/// unpriced back exactly where they were, and the map is then of two different directories.
900fn stamp_of(id: NodeId, counts: Counts, beneath: u64) -> u64 {
901    // FNV-1a, which is two instructions a value against `DefaultHasher`'s SipHash — this runs
902    // once per node per frame over 32,634 of them, so the hash has to cost less than the
903    // redraw it exists to avoid. What it buys over a plain sum is diffusion: `beneath` adds
904    // its children commutatively, and a sum of poorly spread values collides easily.
905    const SEED: u64 = 0xcbf2_9ce4_8422_2325;
906    const PRIME: u64 = 0x0000_0100_0000_01b3;
907    let mut stamp = SEED;
908    for value in [
909        id as u64,
910        counts.visible.bytes,
911        counts.visible.claims as u64,
912        counts.visible.unpriced as u64,
913        counts.chosen.claims as u64,
914        beneath,
915    ] {
916        stamp = (stamp ^ value).wrapping_mul(PRIME);
917    }
918    stamp
919}
920
921/// What the event loop has to do about a keystroke, once the view has done its part.
922#[derive(Clone, Debug, PartialEq, Eq)]
923pub enum Effect {
924    /// Nothing outside the view.
925    None,
926    /// Put the terminal back.
927    Quit,
928    /// Resolve these into a [`Plan`] and hand it back with [`View::ask`].
929    ///
930    /// The view never plans, because planning is filesystem work — every path resolved, every
931    /// check in the safety model applied — and this file has none.
932    ///
933    /// [`Target`]s rather than paths, and the difference is the whole answer to "how much do
934    /// I get back": a target carries what the scan priced, and one built from a bare path
935    /// carries [`Size::Unmeasured`]. Handing the planner paths made the confirmation offer to
936    /// delete 196 KiB of `node_modules` "giving back 0 B", with the tree saying otherwise two
937    /// lines above it.
938    Plan(Vec<Target>),
939    /// The question was answered yes. Remove exactly these.
940    Delete(Vec<PathBuf>),
941    /// Put a size on these claims, which nobody has priced.
942    ///
943    /// Filesystem work, so the view asks for it rather than doing it — the same split
944    /// [`Plan`](Self::Plan) draws. Paths and not [`Target`]s, because the whole point is that
945    /// these carry no size yet: the answer comes back as the prices the walk would have sent.
946    Price(Vec<PathBuf>),
947}
948
949/// One sentence for the footer, and how long it stays there.
950///
951/// # Why not a timer
952///
953/// A timer is the wrong answer for a report of what was **destroyed**. A reader who looked away
954/// while it counted down has no way to get it back, and the thing it described is not on disk
955/// any more — so the one state a countdown leaves them in is "something happened and nothing
956/// will say what". Every lifetime here is therefore a *reader's* action rather than a clock.
957///
958/// # Two lifetimes, because the reports differ in what it costs to miss one
959///
960/// [`passing`](Self::passing) is an ordinary report — what was removed, what was priced, why a
961/// key did nothing. The next thing the reader does takes it away, because by then it describes
962/// the frame before rather than the one in front of them.
963///
964/// [`standing`](Self::standing) names something **refused or failed**. The safety model collects
965/// those and the run exits non-zero on them, so a sentence that says a directory was left alone
966/// or could not be removed is the only place a reader learns that from — and an arrow key
967/// pressed while reading it must not be what takes it away. Nothing incidental clears one: it
968/// goes when it is dismissed, or when a newer report answers a keystroke the reader has just
969/// made.
970///
971/// Both are dismissed by `Esc` and by a press on the footer, which is the rung
972/// [`View::step_back`] takes first.
973#[derive(Clone, Debug, PartialEq, Eq)]
974pub struct Notice {
975    said: String,
976    /// Whether this one outlasts the reader's next action. See the type's docs.
977    stands: bool,
978}
979
980impl Notice {
981    /// An ordinary report, gone by the reader's next action.
982    #[must_use]
983    pub fn passing(said: impl Into<String>) -> Self {
984        Self {
985            said: said.into(),
986            stands: false,
987        }
988    }
989
990    /// One that names something refused or failed, and so waits to be dismissed.
991    #[must_use]
992    pub fn standing(said: impl Into<String>) -> Self {
993        Self {
994            said: said.into(),
995            stands: true,
996        }
997    }
998
999    /// The sentence itself.
1000    #[must_use]
1001    pub fn said(&self) -> &str {
1002        &self.said
1003    }
1004
1005    /// Whether it waits to be dismissed rather than going with the reader's next action.
1006    #[must_use]
1007    pub fn stands(&self) -> bool {
1008        self.stands
1009    }
1010}
1011
1012/// The live view.
1013#[derive(Debug)]
1014#[expect(
1015    clippy::struct_excessive_bools,
1016    reason = "five independent facts about one view — is the walk running, is a removal \
1017              running, do the rows still describe the tree, are the levels in order, was the \
1018              cursor taken away. The lint's advice is a state machine, and these do not form \
1019              one: every combination of them happens."
1020)]
1021pub struct View {
1022    tree: Tree,
1023    sort: Sort,
1024    /// Which rows are open. The root starts open and everything else closed, which is the
1025    /// whole premise: a machine's worth of reclaimable directories, shown as a handful of
1026    /// rows you drill into.
1027    expanded: HashSet<NodeId>,
1028    /// The marked subtrees: a directory each, and the view each was marked through. See
1029    /// [`Marked`].
1030    marks: Vec<Marked>,
1031    /// How many times the selection — the marks or the exclusions — has changed. See
1032    /// [`View::mark_stamp`].
1033    mark_stamp: u64,
1034    /// Directories the reader unmarked individually out of a marked subtree.
1035    ///
1036    /// The other half of the model, and the reason a push-down is not needed: "mark the lot,
1037    /// then keep this one" used to mean marking every sibling along the path, which left a
1038    /// mark per sibling on a level 8,660 wide. An exclusion says the same thing in one entry
1039    /// and — unlike the push-down — keeps saying it as claims stream in underneath.
1040    spared: HashSet<NodeId>,
1041    /// What each node is worth, what of it is selected, and what of that can be seen, by
1042    /// [`NodeId`]. Rebuilt whole once per sync by [`tally`]; empty when there is nothing to
1043    /// compute, which is the view a run opens on.
1044    counts: Vec<Counts>,
1045    /// Every claim the marks select, whether or not the current view shows it. The batch, and
1046    /// the set the counter describes — one list, so the two can never disagree.
1047    selection: Vec<NodeId>,
1048    /// What the map under each node is drawn from, as one number. See [`View::map_stamp`];
1049    /// empty when nothing is drawing a map.
1050    map_stamps: Vec<u64>,
1051    rows: Vec<Row>,
1052    cursor: Option<usize>,
1053    /// Whether the cursor was deselected by something vanishing under it.
1054    ///
1055    /// Without this the "no index fallback" rule would last exactly one frame: the next sync
1056    /// would see an empty anchor, decide this was a view that had never been touched, and put
1057    /// the cursor back on row 0 — the scan root, whose subtree is everything.
1058    deselected: bool,
1059    scroll: usize,
1060    /// How many rows the tree pane can draw. The renderer owns the number and tells the view.
1061    page: usize,
1062    /// What is on screen: the two visibility axes and the `/` pattern, together.
1063    ///
1064    /// One value rather than a filter beside a mode, because a mark stores the whole of it —
1065    /// a mark made under `named · dependencies · /nx` has to keep meaning that when any part
1066    /// of it changes.
1067    lens: Lens,
1068    prompt: Option<Prompt>,
1069    help: Option<usize>,
1070    pending: Option<Pending>,
1071    /// Claims a pricing pass has been asked for and has not answered yet.
1072    ///
1073    /// The one piece of state that stops a gesture from being repeatable into unbounded
1074    /// work: a claim in here is one somebody is already traversing, so a second double click
1075    /// on the row above it asks for nothing. Emptied by [`View::repriced`] on every way a
1076    /// pass can end — see there for why that matters more than it looks.
1077    pricing: HashSet<PathBuf>,
1078    /// Whether the walk is still running, for the header.
1079    scanning: bool,
1080    /// The removal in flight, and where it has got to. A second one would race the first over
1081    /// the same tree, so its presence is also what refuses one.
1082    removing: Option<Removing>,
1083    /// Whether the reader has asked to leave and is waiting on a removal to finish.
1084    ///
1085    /// `q` is reserved everywhere, and it stays reserved — but it cannot *end* a run that is
1086    /// half way through unlinking a directory tree. The process leaving takes the pool with
1087    /// it, so what would be left on disk is neither the tree the reader had nor the one they
1088    /// asked for, and nothing would ever report which. So the keystroke is remembered instead
1089    /// of obeyed, and the loop leaves the moment the removal is over.
1090    quitting: bool,
1091    /// What just happened, for the footer to say — until something takes it away. See
1092    /// [`Notice`].
1093    notice: Option<Notice>,
1094    /// Whether the rows on hand still describe the tree.
1095    stale: bool,
1096    /// Whether the tree's levels are in the current sort order.
1097    sorted: bool,
1098    /// What is in flight on screen: see [`Moving`].
1099    moving: Moving,
1100    /// What has already left the disk from at or below each node, as of this frame. Rebuilt in
1101    /// [`View::animate`] from what the deleter has reported freeing.
1102    ///
1103    /// The bytes here are the deleter's own running total, so a row emptying is the disk
1104    /// emptying and not a timer dressed up as one. The **claims** move separately and later:
1105    /// a target part way through is a directory that still exists, and it stops being counted
1106    /// only when the sweep says it has finished with it.
1107    ///
1108    /// Bounded by the targets one removal has in flight, times the depth of the tree, and
1109    /// built once per frame rather than asked per row.
1110    drained: HashMap<NodeId, Roll>,
1111    /// This frame's instant. Handed in by [`View::animate`] and read by everything else, so
1112    /// nothing in this file calls a clock.
1113    now: Instant,
1114    /// When the view opened, which is what the shimmer's phase is measured from.
1115    opened: Instant,
1116    /// How many [`NodeId`]s the tree had handed out last time this looked, so the ones it has
1117    /// handed out since can be lit as new arrivals.
1118    seen: usize,
1119    /// Directories a removal left standing, and why.
1120    ///
1121    /// The safety model refusing a subtree is the tool **working**, so this is kept apart
1122    /// from the walk's errors and drawn calmly. A reader who marked forty directories and got
1123    /// thirty-eight has to be able to see which two, on the rows themselves, after the footer
1124    /// has moved on.
1125    kept: HashMap<NodeId, String>,
1126    /// What this session has given back, across every batch.
1127    freed: u64,
1128    /// The treemap pane: whether this terminal could draw one, and whether the reader wants
1129    /// it. Told to the view the way [`View::viewport`] is — the renderer owns the fact and
1130    /// the view owns the decision, so `m` has one place to act on and the layout has one
1131    /// place to read.
1132    map: Map,
1133}
1134
1135/// Whether the map pane is possible, and whether it is on.
1136///
1137/// The first is [`Maps`] rather than a boolean, because the answer to `m` differs by *why*: a
1138/// reader on a terminal that cannot draw one has to be told which of the two reasons it is,
1139/// where a silent no-op on a documented key is the same failure shape as a mark box that
1140/// cannot be pressed.
1141#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
1142struct Map {
1143    possible: Maps,
1144    on: bool,
1145}
1146
1147impl View {
1148    /// A view of a scan that has not found anything yet.
1149    #[must_use]
1150    pub fn new(tree: Tree) -> Self {
1151        let opened = Instant::now();
1152        let mut view = Self {
1153            expanded: HashSet::from([tree.root()]),
1154            // The root is not an arrival: it is the directory the reader typed, and lighting
1155            // it on the first frame would say something was found before anything was.
1156            seen: tree.minted(),
1157            tree,
1158            sort: Sort::default(),
1159            marks: Vec::new(),
1160            mark_stamp: 0,
1161            spared: HashSet::new(),
1162            counts: Vec::new(),
1163            selection: Vec::new(),
1164            map_stamps: Vec::new(),
1165            rows: Vec::new(),
1166            cursor: None,
1167            deselected: false,
1168            scroll: 0,
1169            page: 20,
1170            lens: Lens::default(),
1171            prompt: None,
1172            help: None,
1173            pending: None,
1174            pricing: HashSet::new(),
1175            scanning: true,
1176            removing: None,
1177            quitting: false,
1178            notice: None,
1179            stale: true,
1180            sorted: false,
1181            moving: Moving::new(opened),
1182            drained: HashMap::new(),
1183            now: opened,
1184            opened,
1185            kept: HashMap::new(),
1186            freed: 0,
1187            // On wherever it is possible, which is the spike's own bet: a feature nobody
1188            // turns on is a feature nobody judges.
1189            map: Map {
1190                possible: Maps::Unread,
1191                on: true,
1192            },
1193        };
1194        view.sync();
1195        view
1196    }
1197
1198    /// Opens the view with gitignored files on screen, as `--ignored-files` asks for.
1199    ///
1200    /// The walk claims them either way — see [`super::spawn_walk`] — so this decides only where
1201    /// the lens starts. It exists because a flag that reads "claim gitignored files" and then
1202    /// does nothing a reader can see is a flag that has lied about itself: the two front ends
1203    /// have to mean the same thing by it, and in the tree "show me these" is what it means.
1204    #[must_use]
1205    pub fn showing_files(mut self) -> Self {
1206        self.lens = self.lens.with_files(true);
1207        self.stale = true;
1208        self.sync();
1209        self
1210    }
1211
1212    // ---- what the workers report ------------------------------------------------------
1213
1214    /// A claim the walk has just found.
1215    pub fn found(&mut self, hit: Hit) {
1216        self.tree.insert(hit);
1217        self.stale = true;
1218        self.sorted = false;
1219    }
1220
1221    /// A pricing thread has gone into this claim.
1222    ///
1223    /// What the shimmer draws, and the reason it is worth drawing: the pool is bounded, so the
1224    /// rows lit at any instant are exactly the ones being worked on. A dash that is being
1225    /// measured right now and a dash that will be measured in four minutes are different facts
1226    /// about a row, and before this event the front end had no way to tell them apart.
1227    pub fn pricing(&mut self, path: &Path) {
1228        if let Some(id) = self.tree.find(path) {
1229            self.moving.heats(id);
1230        }
1231    }
1232
1233    /// A price for a claim that was published without one.
1234    pub fn priced(&mut self, path: &Path, size: Size) {
1235        if let Some(id) = self.tree.find(path) {
1236            self.moving.cools(id);
1237        }
1238        self.tree.price(path, size);
1239        self.stale = true;
1240        self.sorted = false;
1241    }
1242
1243    /// Bytes the deleter has just given back from a target it is still working through.
1244    ///
1245    /// This is what makes a row **empty** rather than merely disappear, and the reason it is
1246    /// an event rather than a timer: the number falling is the number of bytes that have
1247    /// actually left the disk. A fixed animation started after the fact would look the same
1248    /// for a target that took ten seconds and one that took ten milliseconds, which is the
1249    /// definition of motion that is not information.
1250    pub fn freeing(&mut self, path: &Path, bytes: u64) {
1251        // Before the tree lookup and outside it, because the two answer to different things:
1252        // the footer's arithmetic is about the batch, which is known in full, while the row is
1253        // about a node that may legitimately not be drawn. Folding the first into the second
1254        // makes a missing row silently cost the whole counter.
1255        if let Some(removing) = &mut self.removing {
1256            removing.freeing(path, bytes);
1257        }
1258        if let Some(id) = self.tree.find(path) {
1259            self.moving.frees(id, bytes);
1260            self.stale = true;
1261        }
1262    }
1263
1264    /// A target the deleter has finished with.
1265    ///
1266    /// Only a *complete* removal takes the row away. A target the sweep entered and did not
1267    /// finish — a checkout inside it, an unreadable subtree — is still on disk, and dropping
1268    /// its row would tell a reader something was deleted that was not.
1269    ///
1270    /// A complete one does not take it away on this frame either. Its number is already at
1271    /// zero, having got there on the bytes reported by [`View::freeing`]; what it spends now
1272    /// is [`super::moving::DIM`] dimmed, so the row is seen to have emptied instead of
1273    /// vanishing on the same frame as its last byte. That beat is presentational and its
1274    /// consequences are not: the row is out of the batch and out of the marks from the moment
1275    /// the sweep first touched it.
1276    pub fn removed(&mut self, path: &Path, bytes: u64, complete: bool) {
1277        // The target's last word on itself, and for most targets its only one: a sweep reports
1278        // progress every 64 entries, so anything smaller than that finishes without ever having
1279        // said anything. A counter fed only by [`View::freeing`] would leave every small target
1280        // in a batch worth nothing — which on a real batch is most of them.
1281        //
1282        // Assignment rather than addition, and that is what makes this safe to do beside the
1283        // progress reports: both are the same running total read at different moments.
1284        if let Some(removing) = &mut self.removing {
1285            removing.freeing(path, bytes);
1286        }
1287        let Some(id) = self.tree.find(path) else {
1288            return;
1289        };
1290        if complete {
1291            self.moving.spends(id, bytes, self.now);
1292        } else {
1293            // Still on disk, and the bytes that did go are still gone. The row keeps its
1294            // place showing what is left of it, which is the honest reading of a sweep that
1295            // went in and came out again.
1296            self.moving.frees(id, bytes);
1297        }
1298        self.stale = true;
1299    }
1300
1301    /// The deleter has moved off a target, whatever it managed to do to it.
1302    ///
1303    /// **This and [`View::removed`] answer different questions, which is why the progress
1304    /// counts here and the rows move there.** A target that failed before unlinking a single
1305    /// entry, or that had already vanished, is one the deleter is no longer working on — it
1306    /// belongs to where the batch has got to, and to nothing else. Counting the position on
1307    /// removals instead would leave a batch that failed on every target reading 0% for its
1308    /// whole life and then vanishing, which reports the *outcome* under the guise of the
1309    /// position; and even one such target leaves the bar permanently short of where the
1310    /// deleter actually is.
1311    ///
1312    /// It deliberately does not touch the tree. Nothing happened to that directory, so there
1313    /// is nothing for its row to say.
1314    pub fn swept(&mut self, path: &Path) {
1315        if let Some(removing) = &mut self.removing {
1316            removing.finished(path);
1317        }
1318    }
1319
1320    /// Directories a removal left standing, with the reason each one was left.
1321    pub fn refused(&mut self, kept: &[Refused]) {
1322        for refused in kept {
1323            if let Some(id) = self.tree.find(&refused.path) {
1324                self.kept.insert(id, refused.reason.to_string());
1325            }
1326        }
1327    }
1328
1329    /// The walk is over.
1330    pub fn scanned(&mut self) {
1331        self.scanning = false;
1332        // A pool that has stopped leaves nothing hot behind it.
1333        self.moving.cooled();
1334    }
1335
1336    /// A pricing pass is over, and these are the claims it was holding.
1337    ///
1338    /// The prices themselves arrived one at a time through [`View::priced`], exactly as the
1339    /// walk's do; this hands the claims back, which is what lets the next double click on
1340    /// them mean something again. It is called on **every** way a pass can end, the worker
1341    /// dying included — an in-flight set that leaked would be a subtree the reader can never
1342    /// ask about again for the rest of the run, which is a quiet permanent no-op on a gesture
1343    /// they keep making.
1344    pub fn repriced(&mut self, claims: &[PathBuf], notice: Notice) {
1345        for claim in claims {
1346            self.pricing.remove(claim);
1347        }
1348        self.notice = Some(notice);
1349    }
1350
1351    /// The removal is over, `notice` is what it did, and `freed` is what the session has given
1352    /// back across every batch it has run.
1353    ///
1354    /// The two are the same event told from two ends and they hand over here, which is why they
1355    /// are set in one call. While the batch runs the rows and the counter carry it — bytes
1356    /// falling as they leave the disk, a position against the batch's own size — and none of
1357    /// that survives the last target. `notice` is what is left saying anything at all, so it is
1358    /// the *only* place the counts a live row cannot show land: what the safety model refused,
1359    /// and what failed. How long it stays is picked from those same counts, by
1360    /// [`summarise`](crate::tui::summarise). See [`Notice`].
1361    ///
1362    /// The freed figure is **set** rather than added to, and it is the batch report's own
1363    /// arithmetic rather than a second tally kept here: the per-target totals the counter has
1364    /// been climbing on and [`crate::delete::Removal::bytes_freed`] are the same bytes counted
1365    /// by the same code, so keeping both would count each one twice. The running figures are
1366    /// dropped in the same breath that this one lands, which is why the counter hands over
1367    /// without so much as a flicker.
1368    ///
1369    /// **A target the sweep could not finish has to be reconciled into the tree first.** Its
1370    /// row is staying — the directory is still on disk — and what it is worth is what survived,
1371    /// which until this point has only ever been said by the deleter's progress. Progress is
1372    /// the thing being dropped here, so the reduction is made durable before it goes: a claim
1373    /// whose bytes went but whose row remains springs straight back to its original size
1374    /// otherwise, and the headline reclaimable figure *rises* after a partial delete while
1375    /// `freed` says those same bytes are gone. A complete removal needs none of this, because
1376    /// its claim leaves the tree outright when its dimmed beat is over.
1377    pub fn deleted(&mut self, notice: Notice, freed: u64) {
1378        for (id, bytes) in self.moving.leaving().collect::<Vec<_>>() {
1379            if self.moving.is_spent(id) {
1380                continue;
1381            }
1382            let path = self.tree.node(id).path.clone();
1383            self.tree.shrink(&path, bytes);
1384        }
1385        self.removing = None;
1386        self.notice = Some(notice);
1387        self.freed = freed;
1388        self.moving.banked();
1389        self.stale = true;
1390    }
1391
1392    /// Opens the confirmation on a resolved plan.
1393    ///
1394    /// The listing is built here rather than by the planner, because half of what a line says
1395    /// is a fact about the *view* — what kind of artefact this is, and whether the reader can
1396    /// currently see it — and the planner knows only paths and the safety model's answers.
1397    /// The two halves meet exactly once, here.
1398    ///
1399    /// An empty plan is not a question. It happens for a real reason — every marked directory
1400    /// was refused by the safety model — so it says so rather than putting up a box with
1401    /// nothing in it.
1402    pub fn ask(&mut self, plan: &Plan) {
1403        let targets: Vec<Planned> = plan
1404            .targets()
1405            .iter()
1406            .map(|target| Planned {
1407                requested: target.requested.clone(),
1408                resolved: target.path.clone(),
1409                size: target.size,
1410            })
1411            .collect();
1412        self.asking(&targets, plan.kept());
1413    }
1414
1415    /// The same question from the two lists a [`Plan`] *is*.
1416    ///
1417    /// Taken apart because a plan can only be built against a real filesystem, and the rule
1418    /// this screen has to keep — that the batch is stated whole, hidden entries and refusals
1419    /// included — is a rule about the view rather than about the disk.
1420    pub fn asking(&mut self, targets: &[Planned], kept: &[Refused]) {
1421        if targets.is_empty() {
1422            // A refusal, so it waits to be dismissed: this sentence is the *only* place a
1423            // reader learns that the safety model took their whole batch away. It is also the
1424            // one path on which the confirmation does not appear, so there is nothing else
1425            // left saying anything about the batch at all.
1426            if kept.is_empty() {
1427                self.says("nothing to delete");
1428            } else {
1429                self.warns(format!(
1430                    "nothing to delete: {} left alone by the safety model",
1431                    kept.len()
1432                ));
1433            }
1434            return;
1435        }
1436        let mut entries: Vec<Entry> = targets
1437            .iter()
1438            .map(|target| {
1439                self.entry(
1440                    &target.requested,
1441                    Some(target.resolved.clone()),
1442                    target.size,
1443                    None,
1444                )
1445            })
1446            .chain(kept.iter().map(|refused| {
1447                self.entry(
1448                    &refused.path,
1449                    None,
1450                    Size::Unmeasured,
1451                    Some(refused.reason.to_string()),
1452                )
1453            }))
1454            .collect();
1455        // By kind and then by path, which is what "groups by kind" means once the lines are
1456        // one list: the renderer names the kind wherever it changes rather than keeping a
1457        // second structure that could disagree about the order.
1458        entries.sort_by(|a, b| {
1459            kind_order(a.kind)
1460                .cmp(&kind_order(b.kind))
1461                .then_with(|| a.path.cmp(&b.path))
1462        });
1463        // Added up over the **entries** rather than over the plan, so the headline and the
1464        // lines under it are one arithmetic. They can differ: a target the scan priced and the
1465        // plan did not is a dash in the plan and a number on the row, and a box saying "giving
1466        // back 0 B" over a line saying 2.0 MiB is the disagreement a reader catches first.
1467        let priced = |entry: &&Entry| entry.kept.is_none();
1468        let bytes = entries
1469            .iter()
1470            .filter(priced)
1471            .filter_map(|entry| entry.size.bytes())
1472            .sum();
1473        let unpriced = entries
1474            .iter()
1475            .filter(priced)
1476            .filter(|entry| entry.size.bytes().is_none())
1477            .count();
1478        self.pending = Some(Pending {
1479            // **The requested spelling, not the resolved one**, and the difference is the whole
1480            // of #656's sibling bug. The deed is re-planned before it runs, so either spelling
1481            // reaches the same directory — but whichever goes in is what the deleter calls the
1482            // target when it reports back, and the view can only find a row by the name the
1483            // walk gave it. Hand the resolved path to the deed and every report comes back in
1484            // a spelling the tree has never heard of: no row empties, no row leaves, the
1485            // headline total never falls, and the only thing that still moves is the position,
1486            // because it is the one figure that needs no path.
1487            targets: targets
1488                .iter()
1489                .map(|target| target.requested.clone())
1490                .collect(),
1491            bytes,
1492            unpriced,
1493            entries,
1494            view: self.lens.describe(),
1495            at: 0,
1496            scroll: 0,
1497            page: 8,
1498            answer: Answer::Cancel,
1499        });
1500    }
1501
1502    /// One line of the listing, with everything only the tree and the lens can say.
1503    fn entry(
1504        &self,
1505        path: &Path,
1506        target: Option<PathBuf>,
1507        size: Size,
1508        kept: Option<String>,
1509    ) -> Entry {
1510        let id = self.tree.find(path);
1511        let hit = id.and_then(|id| self.tree.node(id).hit.as_ref());
1512        Entry {
1513            id,
1514            path: path.to_path_buf(),
1515            target,
1516            kind: hit.and_then(Hit::kind),
1517            label: hit.map_or_else(
1518                || crate::walk::UNLABELLED.to_owned(),
1519                |hit| hit.label().into_owned(),
1520            ),
1521            // The plan's own figure where it has one, and the tree's otherwise: a refusal
1522            // carries no size, and a line saying nothing about what it is worth is a line a
1523            // reader cannot weigh.
1524            size: match size.bytes() {
1525                Some(_) => size,
1526                None => hit.map_or(Size::Unmeasured, |hit| hit.size),
1527            },
1528            // "Hidden" is a claim about the **view**, so a path the tree does not hold is not
1529            // one: a view that hides nothing must never be able to report that it is hiding
1530            // something, or the warning at the top of the box stops meaning anything.
1531            hidden: hit.is_some_and(|hit| !self.lens.matches(hit)),
1532            kept,
1533        }
1534    }
1535
1536    // ---- what the renderer asks -------------------------------------------------------
1537
1538    /// Brings the rows back in line with the tree, if anything has changed under them.
1539    ///
1540    /// Idempotent and cheap when nothing moved, so both the renderer and every keystroke can
1541    /// call it without either having to know whether the other did.
1542    pub fn sync(&mut self) {
1543        if !self.stale {
1544            return;
1545        }
1546        let anchor = self.anchor();
1547        if !self.sorted {
1548            self.tree.sort_by(self.sort);
1549            self.sorted = true;
1550        }
1551        // Everything the tree has minted since the last look is a directory the walk found
1552        // since the last look, so it is exactly the set of rows to light. The tree does not
1553        // have to report arrivals for this to be exact: ids are handed out in order and never
1554        // recycled.
1555        for id in self.seen..self.tree.minted() {
1556            self.moving.arrived(id, self.now);
1557        }
1558        self.seen = self.tree.minted();
1559        // A mark or an open row can outlive the directory it names, because the deleter takes
1560        // rows away while the reader is looking at them. Dropped here, once per frame, rather
1561        // than per removal: a batch of ten thousand deletions would otherwise re-scan the
1562        // whole mark set ten thousand times. A draining row goes with them — it is a row for a
1563        // directory that is no longer on the disk, and a mark on one would put it in the next
1564        // batch.
1565        let (tree, moving) = (&self.tree, &self.moving);
1566        let held = (self.marks.len(), self.spared.len());
1567        self.marks
1568            .retain(|mark| tree.is_attached(mark.root) && !moving.is_leaving(mark.root));
1569        self.spared.retain(|&id| tree.is_attached(id));
1570        if (self.marks.len(), self.spared.len()) != held {
1571            self.mark_stamp += 1;
1572        }
1573        self.expanded.retain(|&id| self.tree.is_attached(id));
1574        self.kept.retain(|&id, _| self.tree.is_attached(id));
1575        // A claim the reader deleted while a pricing thread was inside it never gets its
1576        // price, because [`View::priced`] resolves the path and the path is gone — so nothing
1577        // would ever cool it. It would shimmer for a thread that had finished, forever, and
1578        // hold the whole view at the animating frame rate to do it. Bounded by the pool, so
1579        // this is a handful of ids per frame.
1580        for id in self
1581            .moving
1582            .hot()
1583            .filter(|&id| !self.tree.is_attached(id))
1584            .collect::<Vec<_>>()
1585        {
1586            self.moving.cools(id);
1587        }
1588        self.recount();
1589        self.reflatten();
1590        self.settle(&anchor);
1591        self.follow_cursor();
1592        self.stale = false;
1593    }
1594
1595    /// Moves the frame on to `now`: the one place time enters this file.
1596    ///
1597    /// Three things, in an order that matters. A drain that has run its course takes its row
1598    /// out of the tree *first*, so the rows this frame draws are the rows that exist. Then the
1599    /// view is brought back in line. Then every drawn row's number is advanced toward what it
1600    /// is really worth — **drawn** rows, which is what keeps this O(the pane) rather than
1601    /// O(the tree), and the whole reason interpolation is affordable on a view holding 22,765
1602    /// directories.
1603    pub fn animate(&mut self, now: Instant) {
1604        self.now = now;
1605        self.moving.tick(now);
1606        for id in self.moving.collapsed(now) {
1607            let path = self.tree.node(id).path.clone();
1608            self.tree.remove(&path);
1609            self.stale = true;
1610        }
1611        self.sync();
1612        self.recount_drains();
1613        let targets: Vec<(NodeId, u64, bool)> = std::iter::once(self.tree.root())
1614            .chain(
1615                self.rows
1616                    .iter()
1617                    .skip(self.scroll)
1618                    .take(self.page)
1619                    .map(|row| row.id),
1620            )
1621            // Never eased: these numbers come from the deleter and they are already the
1622            // truth, so smoothing them would be putting a guess in front of a measurement.
1623            // The chase is for values that jump — an arrival, a price — and a row emptying
1624            // does not jump, it is reported as it happens.
1625            .map(|id| (id, self.live(id).bytes, self.drained.contains_key(&id)))
1626            .collect();
1627        self.moving.advance(now, &targets, self.freed_total());
1628    }
1629
1630    /// What the session has given back, counting the batch that is running.
1631    ///
1632    /// One source at a time and no overlap between them: while a removal is in flight the
1633    /// figure climbs on the per-target totals the deleter is reporting, and the moment the
1634    /// batch reports its own the running figures are dropped for it. See [`View::deleted`].
1635    fn freed_total(&self) -> u64 {
1636        self.freed + self.moving.freed_so_far()
1637    }
1638
1639    /// Rebuilds [`View::drained`] from what the deleter has reported freeing.
1640    ///
1641    /// One walk up the ancestors per target in flight, which is the same shape as
1642    /// [`View::recount_marks`] and for the same reason: a subtraction applied when a target
1643    /// started would have to be un-applied correctly when it finished, and being wrong leaves
1644    /// a total that never comes back.
1645    ///
1646    /// The two halves move at different times on purpose. **Bytes** come off as they are
1647    /// freed, because they really have gone. **Claims** come off only when the sweep says it
1648    /// has finished with the target, because until then the directory is still there — and
1649    /// "how many directories" is what the selection counter and the batch are stated in, so
1650    /// dropping it early would say a row had been deleted while it was still being deleted.
1651    fn recount_drains(&mut self) {
1652        self.drained.clear();
1653        for (id, freed) in self.moving.leaving().collect::<Vec<_>>() {
1654            let roll = self.roll(id);
1655            let gone = if self.moving.is_spent(id) {
1656                // Finished with. Whatever the sweep managed to count, the directory is gone,
1657                // so the row is worth nothing and its claim stops counting.
1658                roll
1659            } else {
1660                Roll {
1661                    bytes: freed.min(roll.bytes),
1662                    claims: 0,
1663                    unpriced: 0,
1664                }
1665            };
1666            let mut at = Some(id);
1667            while let Some(current) = at {
1668                let drained = self.drained.entry(current).or_default();
1669                drained.bytes += gone.bytes;
1670                drained.claims += gone.claims;
1671                drained.unpriced += gone.unpriced;
1672                at = self.tree.node(current).parent;
1673            }
1674        }
1675    }
1676
1677    /// What a row is worth once what has already left the disk is taken off.
1678    ///
1679    /// The truth the tree itself cannot state, because the tree learns about a removal only
1680    /// when the deleter has finished with the whole target: this is the same number a second
1681    /// or two earlier, falling as the bytes do. It is what the screen draws *and* what a
1682    /// decision is made on — the batch, the selection counter — and there is deliberately no
1683    /// second, laggier version of it for display.
1684    fn live(&self, id: NodeId) -> Roll {
1685        let roll = self.roll(id);
1686        let Some(gone) = self.drained.get(&id) else {
1687            return roll;
1688        };
1689        Roll {
1690            bytes: roll.bytes.saturating_sub(gone.bytes),
1691            claims: roll.claims.saturating_sub(gone.claims),
1692            unpriced: roll.unpriced.saturating_sub(gone.unpriced),
1693        }
1694    }
1695
1696    /// Whether anything on screen is still in motion, which is what the event loop reads to
1697    /// decide how often to repaint.
1698    #[must_use]
1699    pub fn is_moving(&self) -> bool {
1700        self.moving.is_moving()
1701    }
1702
1703    /// Whether this terminal can draw a map, and if not, why. Told **every frame**.
1704    ///
1705    /// Not once at start-up, which is what #656 was: half the answer is the pixel size in the
1706    /// window, and a window can gain or lose that without the terminal changing — a tmux
1707    /// client attaching, a pane moving to a display the terminal measures differently. So the
1708    /// layout reads a fact that is re-taken as often as it is used.
1709    ///
1710    /// Which makes the early return load-bearing rather than tidy: this runs ten times a
1711    /// second, and marking the view stale each time would re-fold every stamp in the tree to
1712    /// learn that nothing had changed.
1713    pub fn allow_maps(&mut self, possible: Maps) {
1714        if self.map.possible == possible {
1715            return;
1716        }
1717        // A map that was on the screen and cannot be now is worth one line. The reader did not
1718        // ask for the columns back and nothing else on the frame explains where the picture
1719        // went — which is the same courtesy `m` gives, in the one other place the answer can
1720        // change out from under somebody.
1721        if let (true, Some(why)) = (self.maps(), possible.why()) {
1722            self.says(why);
1723        }
1724        self.map.possible = possible;
1725        // Stale because the answer decides whether [`View::map_stamp`] has a table behind it,
1726        // and this is told to the view *after* it opened: without it the first frames of a run
1727        // would answer the map's "has anything changed" from the tree's lens-blind stamp, and
1728        // then swap to the folded one mid-scan for a picture that had not moved.
1729        self.stale = true;
1730    }
1731
1732    /// Whether the map pane is on the screen.
1733    #[must_use]
1734    pub fn maps(&self) -> bool {
1735        self.map.possible.can() && self.map.on
1736    }
1737
1738    /// How many rows the tree pane can draw. Set by the renderer, used by the page keys.
1739    pub fn viewport(&mut self, page: usize) {
1740        self.page = page.max(1);
1741        self.follow_cursor();
1742    }
1743
1744    /// The visible rows, outermost first.
1745    #[must_use]
1746    pub fn rows(&self) -> &[Row] {
1747        &self.rows
1748    }
1749
1750    /// Which row the cursor is on, if any.
1751    #[must_use]
1752    pub fn cursor(&self) -> Option<usize> {
1753        self.cursor
1754    }
1755
1756    /// The first row drawn.
1757    #[must_use]
1758    pub fn scroll(&self) -> usize {
1759        self.scroll
1760    }
1761
1762    /// The tree behind the rows, for the renderer to read names and hits off.
1763    #[must_use]
1764    pub fn tree(&self) -> &Tree {
1765        &self.tree
1766    }
1767
1768    /// Whether this row is open.
1769    #[must_use]
1770    pub fn is_expanded(&self, id: NodeId) -> bool {
1771        self.expanded.contains(&id)
1772    }
1773
1774    /// What this row's subtree is worth — under the current view, which is the only number a
1775    /// row is allowed to state.
1776    #[must_use]
1777    pub fn roll(&self, id: NodeId) -> Roll {
1778        if self.is_sifted() {
1779            return self
1780                .counts
1781                .get(id)
1782                .map_or_else(Roll::default, |counts| counts.visible);
1783        }
1784        let node = self.tree.node(id);
1785        Roll {
1786            bytes: node.reclaimable,
1787            claims: node.claims,
1788            unpriced: node.unmeasured,
1789        }
1790    }
1791
1792    /// What this row is drawing at this instant, which is the truth once it has caught up.
1793    ///
1794    /// A rolled-up total climbing toward its real value is the one thing a count of
1795    /// directories cannot say: how fast the scan is finding them. It is also the same
1796    /// mechanism, running the other way, that empties a row the deleter has just finished
1797    /// with. Only the *bytes* move — the claim counts do not, because a count is a thing a
1798    /// reader reads off rather than watches.
1799    #[must_use]
1800    pub fn drawn(&self, id: NodeId) -> Roll {
1801        Roll {
1802            bytes: self.moving.shown(id, self.live(id).bytes),
1803            ..self.live(id)
1804        }
1805    }
1806
1807    /// The header's number, climbing.
1808    #[must_use]
1809    pub fn drawn_total(&self) -> Roll {
1810        self.drawn(self.tree.root())
1811    }
1812
1813    /// What this session has given back, climbing — the other of the two counters a removal
1814    /// moves, and the one that goes up.
1815    #[must_use]
1816    pub fn drawn_freed(&self) -> u64 {
1817        self.moving.freed()
1818    }
1819
1820    /// Whether anything has been freed this session at all.
1821    ///
1822    /// True as soon as the first bytes leave rather than when the batch reports, so the
1823    /// counter that climbs is on screen for the whole of the fall it is the counterpart to.
1824    #[must_use]
1825    pub fn has_freed(&self) -> bool {
1826        self.freed_total() > 0
1827    }
1828
1829    /// How lit a newly found row is, from 1.0 down to 0.0 over about a second.
1830    #[must_use]
1831    pub fn freshness(&self, id: NodeId) -> f64 {
1832        self.moving.freshness(id)
1833    }
1834
1835    /// Whether a pricing thread is inside this claim at this instant.
1836    #[must_use]
1837    pub fn is_pricing(&self, id: NodeId) -> bool {
1838        self.moving.is_hot(id)
1839    }
1840
1841    /// Whether bytes are leaving this row right now.
1842    #[must_use]
1843    pub fn is_freeing(&self, id: NodeId) -> bool {
1844        self.moving.is_freeing(id)
1845    }
1846
1847    /// Whether this row has emptied and is spending its last moment on screen.
1848    #[must_use]
1849    pub fn is_spent(&self, id: NodeId) -> bool {
1850        self.moving.is_spent(id)
1851    }
1852
1853    /// Whether the deleter has touched this row at all — either phase.
1854    ///
1855    /// The one predicate the batch, the marks and `space` all read, so "a directory the
1856    /// deleter is part way through is not a directory to delete again" is stated once rather
1857    /// than in three places that could drift.
1858    fn is_leaving(&self, id: NodeId) -> bool {
1859        self.moving.is_freeing(id) || self.moving.is_spent(id)
1860    }
1861
1862    /// Whether the mark cascade is passing through this row right now.
1863    #[must_use]
1864    pub fn is_cascading(&self, id: NodeId) -> bool {
1865        self.moving.is_cascading(id)
1866    }
1867
1868    /// Which cell of a `width`-wide pricing shimmer is lit this frame.
1869    #[must_use]
1870    pub fn shimmer(&self, width: usize) -> usize {
1871        self.moving.shimmer(width, self.opened)
1872    }
1873
1874    /// Why a removal left this directory standing, if it did.
1875    #[must_use]
1876    pub fn kept_reason(&self, id: NodeId) -> Option<&str> {
1877        self.kept.get(&id).map(String::as_str)
1878    }
1879
1880    /// How much of this row's subtree is marked, **as the current view shows it**.
1881    ///
1882    /// Filter-relative, and that is a correctness property rather than a nicety. A directory
1883    /// can be entirely marked under `dependencies` and only partly marked under `all`, so a
1884    /// glyph computed against the whole tree would contradict the rows the reader can see
1885    /// directly underneath it. What the box says is a statement about this screen.
1886    ///
1887    /// A row with nothing visible under it is [`Mark::None`] rather than [`Mark::All`]: an
1888    /// empty set is not something to draw as fully marked, and the row is not on screen
1889    /// anyway.
1890    #[must_use]
1891    pub fn mark_of(&self, id: NodeId) -> Mark {
1892        let Some(counts) = self.counts.get(id) else {
1893            return Mark::None;
1894        };
1895        let whole = self.roll(id).claims;
1896        if counts.chosen.claims == 0 || whole == 0 {
1897            Mark::None
1898        } else if counts.chosen.claims >= whole {
1899            Mark::All
1900        } else {
1901            Mark::Partial
1902        }
1903    }
1904
1905    /// What the map of `id` is drawn from, as one number.
1906    ///
1907    /// Everything [`super::treemap`] reads under `id` and nothing else, so it changes when the
1908    /// picture would and does not when it would not. The distinction that matters is against
1909    /// [`Tree::stamp`](crate::tree::Tree::stamp), which the tree keeps for free but which is
1910    /// **lens-blind**: a run opens on `default`, which hides the gitignored tier, so a tier-two
1911    /// claim arriving under the mapped directory moves the tree's stamp while changing no
1912    /// rectangle at all. Answering that with a redraw is a megabyte down the pty to show the
1913    /// picture that was already there.
1914    ///
1915    /// So it is folded on the one pass that is already lens-aware — [`tally`], which computes
1916    /// what every row is worth *under the current view* — rather than maintained beside it. An
1917    /// incremental version would have to be right about every arrival, every price and every
1918    /// deletion, which is the same argument this file already makes about the counts
1919    /// themselves.
1920    ///
1921    /// **Falls back to the tree's stamp when there is no map**, which over-reports rather than
1922    /// under-reports: [`tally`] does not fold what nobody is going to ask for, and a view that
1923    /// was never told a map is possible is a view with no pane to spend the redraw on.
1924    #[must_use]
1925    pub fn map_stamp(&self, id: NodeId) -> u64 {
1926        self.map_stamps
1927            .get(id)
1928            .copied()
1929            .unwrap_or_else(|| self.tree.stamp(id))
1930    }
1931
1932    /// How many times the selection has changed since the view opened — the marks or the
1933    /// exclusions, either way round.
1934    ///
1935    /// For a reader of the view that has to answer "is this the same picture as last frame"
1936    /// without rebuilding the picture — [`super::treemap`], whose rectangles change colour on
1937    /// a mark. Nothing else says so: a mark moves no bytes and no claims, so the tree's own
1938    /// [`Tree::stamp`](crate::tree::Tree::stamp) is silent about it.
1939    ///
1940    /// A count and not a hash of what is marked, because the two states such a hash would
1941    /// most easily call equal — unmarking one directory and marking its equally sized
1942    /// neighbour — are the ones a reader is most likely to produce. It counts keystrokes
1943    /// rather than differences, so it can say a selection changed when it did not: that costs
1944    /// one redraw on a key the reader pressed, where the other way round is a picture that
1945    /// disagrees with the tree beside it.
1946    #[must_use]
1947    pub fn mark_stamp(&self) -> u64 {
1948        self.mark_stamp
1949    }
1950
1951    /// What share of this row's subtree is marked, between 0.0 and 1.0.
1952    ///
1953    /// By **bytes**, which is what a reader deciding whether a partial ancestor is worth
1954    /// opening actually wants: forty marked directories out of fifty means nothing if the
1955    /// other ten hold all the space. Claims are the fallback for a subtree nobody has priced
1956    /// yet, where bytes cannot answer and the count is the only thing that is true.
1957    #[must_use]
1958    pub fn share(&self, id: NodeId) -> f64 {
1959        if self.mark_of(id) == Mark::All {
1960            return 1.0;
1961        }
1962        let whole = self.roll(id);
1963        let marked = self
1964            .counts
1965            .get(id)
1966            .map_or_else(Roll::default, |counts| counts.chosen);
1967        #[expect(
1968            clippy::cast_precision_loss,
1969            reason = "a ratio bound for one of seven block glyphs has no precision to lose"
1970        )]
1971        // Bytes only once everything under here has a number on it. Part way through a
1972        // breakdown a byte share would be a share of what happens to be priced — which reads
1973        // as "nearly all of this is marked" for a subtree whose one marked claim is the only
1974        // one anybody has measured. Claims are always known, so they are what the glyph
1975        // reports until bytes can be trusted, and it converges as the pool catches up.
1976        let share = match (whole.unpriced, whole.bytes, whole.claims) {
1977            (0, bytes @ 1.., _) => marked.bytes as f64 / bytes as f64,
1978            (_, _, claims @ 1..) => marked.claims as f64 / claims as f64,
1979            _ => 0.0,
1980        };
1981        share.clamp(0.0, 1.0)
1982    }
1983
1984    /// What is marked, all together — the selection counter.
1985    ///
1986    /// **The whole selection, not the visible part of it.** Deleting acts on everything that
1987    /// is marked, so a counter stating only what is on screen would be the one number a reader
1988    /// checks disagreeing with the one thing the tool then does. What the view being narrowed
1989    /// changes is [`View::hidden`] beside it, which says how much of this the reader cannot
1990    /// currently see.
1991    ///
1992    /// Net of the rows the deleter has already finished with, which are in the tree for
1993    /// another third of a second while they empty. They are out of [`View::batch`], so they
1994    /// have to be out of the number that describes it.
1995    #[must_use]
1996    pub fn marked(&self) -> Roll {
1997        self.counts
1998            .get(self.tree.root())
1999            .map_or_else(Roll::default, |counts| counts.all)
2000    }
2001
2002    /// How many marked directories the current view is hiding.
2003    ///
2004    /// Zero on a view that hides nothing, which is where a run starts. Above zero it is the
2005    /// hazard orthogonal selection creates, stated on the footer before the reader ever
2006    /// reaches the confirmation that spells it out.
2007    #[must_use]
2008    pub fn hidden(&self) -> usize {
2009        self.counts
2010            .get(self.tree.root())
2011            .map_or(0, |counts| counts.all.claims - counts.chosen.claims)
2012    }
2013
2014    /// The whole scan, as the header states it — under the current view.
2015    #[must_use]
2016    pub fn total(&self) -> Roll {
2017        self.roll(self.tree.root())
2018    }
2019
2020    /// How many claims the scan found that the current view is not showing.
2021    ///
2022    /// **The header says this, and that is what keeps a narrowed view honest.** The run opens
2023    /// on `default`, which hides the gitignored tier — a real tier worth real bytes that no
2024    /// other tool finds at all — so the headline count is not the whole answer to "how much do
2025    /// I get back". A number that is narrowed without saying so is the "silently keeps" failure
2026    /// the age floor was resolved against; saying so, on the line the number is on, is the
2027    /// difference between a filter and a lie.
2028    #[must_use]
2029    pub fn out_of_view(&self) -> usize {
2030        self.tree
2031            .node(self.tree.root())
2032            .claims
2033            .saturating_sub(self.total().claims)
2034    }
2035
2036    /// The applied filter's pattern, if there is one.
2037    #[must_use]
2038    pub fn filter(&self) -> Option<&str> {
2039        self.lens.pattern()
2040    }
2041
2042    /// Which named view the view is on, or `None` once the axis keys have taken it off all
2043    /// four.
2044    ///
2045    /// Derived from the axes rather than remembered, which the four presets occupying four
2046    /// distinct points is what buys: a reader who toggles their way onto `dependencies` is on
2047    /// `dependencies`, and nothing has to keep a record of how they got there.
2048    #[must_use]
2049    pub fn preset(&self) -> Option<Preset> {
2050        self.lens.preset()
2051    }
2052
2053    /// What the footer calls the view: a preset's name, or the two axes spelled out.
2054    ///
2055    /// A lens the axis keys built has no name, and inventing one — or rounding it to the
2056    /// nearest preset — would tell the reader they are somewhere they are not.
2057    #[must_use]
2058    pub fn view_label(&self) -> String {
2059        self.preset().map_or_else(
2060            || self.lens.axes_label(),
2061            |preset| preset.label().to_owned(),
2062        )
2063    }
2064
2065    /// The whole of what decides visibility, for anything that has to say what is hiding
2066    /// something.
2067    #[must_use]
2068    pub fn lens(&self) -> &Lens {
2069        &self.lens
2070    }
2071
2072    /// The prompt, while it is up.
2073    #[must_use]
2074    pub fn prompt(&self) -> Option<&Prompt> {
2075        self.prompt.as_ref()
2076    }
2077
2078    /// How far the help overlay is scrolled, while it is up.
2079    #[must_use]
2080    pub fn help(&self) -> Option<usize> {
2081        self.help
2082    }
2083
2084    /// Holds the help overlay's scroll inside the page that was actually drawn.
2085    ///
2086    /// The renderer's job because it is the only thing that knows how long the page is and
2087    /// how much of it fits, which is why `G` here means "as far as it goes" rather than a
2088    /// number: a view that guessed would scroll a help page off the top of its own box.
2089    pub fn clamp_help(&mut self, furthest: usize) {
2090        if let Some(at) = self.help {
2091            self.help = Some(at.min(furthest));
2092        }
2093    }
2094
2095    /// The question waiting for an answer.
2096    #[must_use]
2097    pub fn pending(&self) -> Option<&Pending> {
2098        self.pending.as_ref()
2099    }
2100
2101    /// How many lines of the batch the confirmation has room for.
2102    ///
2103    /// The renderer's to say, exactly as [`View::viewport`] is, and for the same reason: the
2104    /// box's height depends on the frame, and a page size the view guessed would scroll the
2105    /// listing past its own border.
2106    pub fn listing(&mut self, page: usize) {
2107        if let Some(pending) = &mut self.pending {
2108            pending.page = page.max(1);
2109            pending.follow();
2110        }
2111    }
2112
2113    /// Whether the walk is still running.
2114    #[must_use]
2115    pub fn is_scanning(&self) -> bool {
2116        self.scanning
2117    }
2118
2119    /// Whether a removal is in flight.
2120    #[must_use]
2121    pub fn is_deleting(&self) -> bool {
2122        self.removing.is_some()
2123    }
2124
2125    /// Where the removal in flight has got to, if there is one.
2126    #[must_use]
2127    pub fn removing(&self) -> Option<&Removing> {
2128        self.removing.as_ref()
2129    }
2130
2131    /// Puts the view mid-removal without one having happened.
2132    ///
2133    /// The event loop's own tests need a view that is waiting on a deleter, and the honest
2134    /// door into that state runs an actual removal against an actual filesystem.
2135    #[cfg(test)]
2136    pub(crate) fn deleting_for_test(&mut self) {
2137        self.removing = Some(Removing::new(&[(PathBuf::from("/scan/target"), 0)]));
2138    }
2139
2140    /// What just happened, for the footer.
2141    #[must_use]
2142    pub fn notice(&self) -> Option<&str> {
2143        self.notice.as_ref().map(|notice| notice.said.as_str())
2144    }
2145
2146    /// Whether what the footer is saying waits to be dismissed rather than going with the
2147    /// reader's next action.
2148    ///
2149    /// Deliberately not something the frame draws differently: a sentence saying a subtree was
2150    /// left alone is the safety model working, and an alarm-coloured footer would teach a
2151    /// reader that correct behaviour is a failure. See [`Notice`].
2152    #[must_use]
2153    pub fn notice_stands(&self) -> bool {
2154        self.notice.as_ref().is_some_and(|notice| notice.stands)
2155    }
2156
2157    /// Says something in the footer until the reader's next action.
2158    fn says(&mut self, said: impl Into<String>) {
2159        self.notice = Some(Notice::passing(said));
2160    }
2161
2162    /// Says something that waits to be dismissed, because it names a refusal or a failure.
2163    fn warns(&mut self, said: impl Into<String>) {
2164        self.notice = Some(Notice::standing(said));
2165    }
2166
2167    /// Drops what the footer is saying, if the reader's next action has made it stale.
2168    ///
2169    /// A standing notice survives this — that is the whole of what "standing" means, and it is
2170    /// why the two are one field with a flag rather than two independent messages: there is
2171    /// only ever one footer, so the last thing said is the thing shown either way.
2172    fn expire(&mut self) {
2173        if !self.notice_stands() {
2174            self.notice = None;
2175        }
2176    }
2177
2178    /// Which sort the levels are in.
2179    #[must_use]
2180    pub fn sort(&self) -> Sort {
2181        self.sort
2182    }
2183
2184    /// Which surface has the keyboard.
2185    ///
2186    /// Ranked rather than exclusive, because the globals can open a prompt or the help page
2187    /// over a question: the confirmation is bottom of the stack, not top.
2188    #[must_use]
2189    pub fn overlay(&self) -> Option<Overlay> {
2190        if self.prompt.is_some() {
2191            Some(Overlay::Prompt)
2192        } else if self.help.is_some() {
2193            Some(Overlay::Help)
2194        } else if self.pending.is_some() {
2195            Some(Overlay::Confirm)
2196        } else {
2197            None
2198        }
2199    }
2200
2201    // ---- what a keystroke does --------------------------------------------------------
2202
2203    /// Carries out one action, and says what the event loop has to do about it.
2204    pub fn apply(&mut self, action: Action) -> Effect {
2205        self.sync();
2206        // What the footer says describes the frame *before* this keystroke, so the keystroke
2207        // takes it away — before the action runs, so that an action with something of its own
2208        // to say still gets the last word. `Ignore` is left out because a key nobody bound is
2209        // not the reader acting, and `Back` because dismissing is a rung of its own: one `Esc`
2210        // must step back exactly once. See [`Notice`].
2211        if !matches!(action, Action::Ignore | Action::Back) {
2212            self.expire();
2213        }
2214        match action {
2215            Action::Quit => return self.quit(),
2216            Action::Ignore => {}
2217            Action::Help => {
2218                self.help = if self.help.is_some() { None } else { Some(0) };
2219            }
2220            Action::Back => self.step_back(),
2221            // One rung, never the one below it: see [`Action::Dismiss`].
2222            Action::Dismiss => self.notice = None,
2223            Action::OpenFilter => {
2224                self.prompt = Some(Prompt::seeded(self.filter().unwrap_or_default()));
2225            }
2226            Action::Type(character) => self.edit(|prompt| {
2227                prompt.chars.insert(prompt.caret, character);
2228                prompt.caret += 1;
2229            }),
2230            Action::Erase => self.edit(|prompt| {
2231                if prompt.caret > 0 {
2232                    prompt.caret -= 1;
2233                    prompt.chars.remove(prompt.caret);
2234                }
2235            }),
2236            Action::EraseAhead => self.edit(|prompt| {
2237                if prompt.caret < prompt.chars.len() {
2238                    prompt.chars.remove(prompt.caret);
2239                }
2240            }),
2241            Action::Wipe => self.edit(|prompt| {
2242                prompt.chars.clear();
2243                prompt.caret = 0;
2244            }),
2245            Action::Caret(motion) => self.edit(|prompt| {
2246                prompt.caret = match motion {
2247                    Motion::Up | Motion::PageUp => prompt.caret.saturating_sub(1),
2248                    Motion::Down | Motion::PageDown => (prompt.caret + 1).min(prompt.chars.len()),
2249                    Motion::Top => 0,
2250                    Motion::Bottom => prompt.chars.len(),
2251                };
2252            }),
2253            Action::Submit => self.submit(),
2254            Action::Scroll(motion) => self.scroll_help(motion),
2255            Action::Highlight(turn) => {
2256                if let Some(pending) = &mut self.pending {
2257                    pending.answer = match turn {
2258                        Turn::Prev => Answer::Cancel,
2259                        Turn::Next => Answer::Delete,
2260                    };
2261                }
2262            }
2263            Action::Answer => return self.answer(),
2264            Action::Listing(motion) => {
2265                if let Some(pending) = &mut self.pending {
2266                    pending.walk(motion);
2267                }
2268            }
2269            Action::Spare => self.spare_entry(),
2270            Action::CyclePreset(turn) => self.cycle_preset(turn),
2271            Action::CycleTiers => self.cycle_tiers(),
2272            Action::ToggleFiles => self.toggle_files(),
2273            Action::ToggleKind(kind) => self.toggle_kind(kind),
2274            Action::Cursor(motion) => self.move_cursor(motion),
2275            Action::ScrollRows(motion) => self.scroll_rows(motion),
2276            Action::Expand => self.expand(),
2277            Action::Collapse => self.collapse(),
2278            Action::ToggleSubtree => self.toggle_subtree(),
2279            Action::CollapseAll => {
2280                self.expanded.retain(|&id| id == self.tree.root());
2281                self.stale = true;
2282            }
2283            Action::Mark => self.toggle_mark(),
2284            Action::MarkAll => self.mark_all(),
2285            Action::Commit => return self.commit(),
2286            Action::ToggleMap => self.toggle_map(),
2287            Action::CycleSort => self.resort(Sort {
2288                by: self.sort.by.next(),
2289                reverse: self.sort.reverse,
2290            }),
2291            Action::ReverseSort => self.resort(Sort {
2292                reverse: !self.sort.reverse,
2293                ..self.sort
2294            }),
2295            Action::SortBy(order) => self.sort_by(order),
2296            Action::Select(id) => {
2297                self.point_at(id);
2298            }
2299            Action::OpenRow(id) => self.open_row(id),
2300            Action::MarkRow(id) => self.mark_row(id),
2301            Action::Price(id) => return self.price_row(id),
2302        }
2303        self.sync();
2304        Effect::None
2305    }
2306
2307    /// `m`: the map pane, or the reason there is not one.
2308    ///
2309    /// A terminal that cannot draw one is told so rather than left pressing a documented key
2310    /// that does nothing — the same rule as a mark box that is drawn and cannot be pressed.
2311    /// **Which** reason matters: "no pixel size" is usually a multiplexer in the way and is
2312    /// something the reader can do something about, where "not on the allowlist" is not.
2313    fn toggle_map(&mut self) {
2314        if let Some(why) = self.map.possible.why() {
2315            self.says(why);
2316            return;
2317        }
2318        self.map.on = !self.map.on;
2319        // As in [`View::allow_maps`]: the pane coming back has to find its stamps built.
2320        self.stale = true;
2321    }
2322
2323    /// `q`: leave — unless something irreversible is in flight, in which case wait for it.
2324    ///
2325    /// The wait is bounded by the work the reader themselves asked for, and it is *visible*:
2326    /// rows keep disappearing as the deleter finishes each target. Tearing the batch in half
2327    /// would not be.
2328    fn quit(&mut self) -> Effect {
2329        if self.is_deleting() {
2330            self.quitting = true;
2331            self.says("the removal has to finish — closing the moment it does");
2332            return Effect::None;
2333        }
2334        Effect::Quit
2335    }
2336
2337    /// Whether a quit that was held back by a removal can be honoured now.
2338    #[must_use]
2339    pub fn wants_to_quit(&self) -> bool {
2340        self.quitting && !self.is_deleting()
2341    }
2342
2343    /// One rung down the ladder: whatever is in front of the reader, taken away.
2344    ///
2345    /// Never quits, which is the rule that makes `Esc` safe to press without looking. The
2346    /// bottom rung is doing nothing at all; `q` is the way out and it is on every help page.
2347    ///
2348    /// The rungs are in front-to-back order, and the notice sits where it does for two reasons.
2349    /// It is *behind* the overlays — the confirmation included — because they are literally
2350    /// drawn over it, and the prompt borrows the footer, so while one is up there is no notice
2351    /// on the screen to dismiss. It is *in front of* the narrowings and the marks because it is
2352    /// the cheapest rung to take by mistake: an `Esc` that dropped forty marks when the reader
2353    /// meant to get rid of a sentence is the one outcome this ladder exists to prevent.
2354    fn step_back(&mut self) {
2355        if self.prompt.take().is_some() {
2356            return;
2357        }
2358        if self.help.take().is_some() {
2359            return;
2360        }
2361        if self.pending.take().is_some() {
2362            return;
2363        }
2364        if self.notice.take().is_some() {
2365            return;
2366        }
2367        // The narrowings come off in the order they were put on: the pattern first, then the
2368        // preset. Two rungs rather than one, because they are two independent things and a
2369        // reader who typed a pattern over `dependencies` means to lose the pattern.
2370        if self.lens.pattern().is_some() {
2371            self.lens = self.lens.clone().matching(None);
2372            self.stale = true;
2373            return;
2374        }
2375        if self.lens != Lens::default() {
2376            self.lens = Lens::default();
2377            self.stale = true;
2378            return;
2379        }
2380        if !self.marks.is_empty() {
2381            self.clear_marks();
2382        }
2383    }
2384
2385    fn edit(&mut self, change: impl FnOnce(&mut Prompt)) {
2386        if let Some(prompt) = &mut self.prompt {
2387            change(prompt);
2388            prompt.error = None;
2389        }
2390    }
2391
2392    /// Applies what has been typed, or says why it cannot be.
2393    ///
2394    /// A pattern the engine refuses leaves the prompt up with the reason under it. Closing it
2395    /// and quietly showing an unfiltered tree would look exactly like a filter that matched
2396    /// everything.
2397    fn submit(&mut self) {
2398        let Some(prompt) = &mut self.prompt else {
2399            return;
2400        };
2401        let pattern = prompt.text();
2402        if pattern.is_empty() {
2403            self.prompt = None;
2404            self.lens = self.lens.clone().matching(None);
2405            self.stale = true;
2406            return;
2407        }
2408        match Regex::new(&pattern) {
2409            Ok(regex) => {
2410                self.lens = self.lens.clone().matching(Some(regex));
2411                self.prompt = None;
2412                self.stale = true;
2413            }
2414            Err(err) => {
2415                let reason = err.to_string();
2416                prompt.error = Some(reason.lines().last().unwrap_or("not a regex").to_owned());
2417            }
2418        }
2419    }
2420
2421    /// `f` and `F`: the next named view, or the one before.
2422    ///
2423    /// A preset changes what is on screen and — deliberately — nothing about what is
2424    /// selected. That is the rule the whole model turns on, and it is why the notice says what
2425    /// the new view *hides* rather than what it shows: a claim that has gone from the screen
2426    /// is still in the batch, and the reader has to be able to tell that from a claim that was
2427    /// never found.
2428    fn cycle_preset(&mut self, turn: Turn) {
2429        // A reader who has moved an axis by hand is not on a preset at all, so the key puts
2430        // them back on the first one rather than stepping from a place they never were.
2431        let next = match self.preset() {
2432            Some(at) => match turn {
2433                Turn::Next => at.next(),
2434                Turn::Prev => at.prev(),
2435            },
2436            None => Preset::default(),
2437        };
2438        // The axes move and the `/` pattern does not. It narrows whatever the axes leave, so it
2439        // is orthogonal to both of them — and a preset that quietly dropped it would be using
2440        // an unrelated piece of state to mean something about the view.
2441        self.lens = Lens::showing(next).matching(self.held_pattern());
2442        self.narrowed(format!("showing {}", next.what()));
2443    }
2444
2445    /// `t`: the tier axis, on its own.
2446    fn cycle_tiers(&mut self) {
2447        let tiers = self.lens.tiers().next();
2448        self.lens = self.lens.clone().with_tiers(tiers);
2449        self.off_the_presets();
2450    }
2451
2452    /// `i`: gitignored files, on their own.
2453    fn toggle_files(&mut self) {
2454        let files = !self.lens.files();
2455        self.lens = self.lens.clone().with_files(files);
2456        self.off_the_presets();
2457    }
2458
2459    /// `u` `d` `b` `c` `n`: one member of the kind axis, on its own.
2460    fn toggle_kind(&mut self, kind: Kind) {
2461        let kinds = self.lens.kinds().toggling(kind);
2462        self.lens = self.lens.clone().with_kinds(kinds);
2463        self.off_the_presets();
2464    }
2465
2466    /// Applies an axis edit: the view is now whatever the two axes say, and not a preset.
2467    ///
2468    /// It says what the *axes* are rather than what the step was called, because there is no
2469    /// name to give — that is the whole point of the two keys, and rounding to the nearest
2470    /// preset would report a view the reader is not on.
2471    fn off_the_presets(&mut self) {
2472        // Nothing to record: [`View::preset`] reads the axes, so a hand-edited lens that lands
2473        // on a preset's point *is* that preset and one that does not has no name. What it says
2474        // is the axes, because that is the only description a nameless view has.
2475        let said = self.lens.axes_label();
2476        self.narrowed(format!("showing {said}"));
2477    }
2478
2479    /// The `/` pattern as a fresh engine, for a lens being rebuilt around it.
2480    fn held_pattern(&self) -> Option<Regex> {
2481        self.lens.pattern().and_then(|held| Regex::new(held).ok())
2482    }
2483
2484    /// Re-derives after a change of view and says what it left out.
2485    ///
2486    /// **The sentence names what is now missing**, which is the whole of what keeps a narrowed
2487    /// view from being the "silently keeps" failure: a claim that has gone from the screen is
2488    /// still in the batch, and a reader has no way to tell that from a claim that was never
2489    /// found unless something says so.
2490    ///
2491    /// [`Notice::passing`] rather than standing, and the count is what makes that safe rather
2492    /// than a hole. This sentence names nothing refused and nothing failed — it answers the
2493    /// keystroke that narrowed the view, so the reader's next one has seen it. What must not
2494    /// perish is the *fact* it carries, and that does not live here: the footer states how much
2495    /// of the selection is out of sight on every frame, notice or no notice, and the
2496    /// confirmation states it again on the one screen where it can still change a decision.
2497    /// A standing notice would instead park one keystroke's echo over the keys until it was
2498    /// dismissed, which is the report outliving what it reports on.
2499    fn narrowed(&mut self, said: String) {
2500        self.stale = true;
2501        self.sync();
2502        let hidden = self.hidden();
2503        self.says(if hidden == 0 {
2504            said
2505        } else {
2506            format!(
2507                "{said} · {} still marked and out of sight",
2508                plural(hidden, "directory", "directories")
2509            )
2510        });
2511    }
2512
2513    fn scroll_help(&mut self, motion: Motion) {
2514        let Some(at) = self.help else {
2515            return;
2516        };
2517        let page = self.page;
2518        self.help = Some(match motion {
2519            Motion::Up => at.saturating_sub(1),
2520            Motion::Down => at + 1,
2521            Motion::PageUp => at.saturating_sub(page),
2522            Motion::PageDown => at + page,
2523            Motion::Top => 0,
2524            // Clamped by the renderer against the page it actually drew, which is the only
2525            // place the length is known.
2526            Motion::Bottom => usize::MAX,
2527        });
2528    }
2529
2530    /// Takes the highlighted answer.
2531    ///
2532    /// The deed is the targets the dialog was holding, never a fresh reading of the marks:
2533    /// see [`Pending`].
2534    fn answer(&mut self) -> Effect {
2535        let Some(pending) = self.pending.take() else {
2536            return Effect::None;
2537        };
2538        match pending.answer {
2539            Answer::Cancel => Effect::None,
2540            Answer::Delete => {
2541                // The batch's size is fixed here and nowhere else, which is what makes the
2542                // progress a fraction rather than a running total. No notice beside it: the
2543                // footer draws the count while this is set, and a static "removing 12
2544                // directories…" sitting next to a live "removing 4 of 12" would be two
2545                // statements about one batch that stop agreeing on the second target.
2546                //
2547                // Cleared outright rather than expired, standing ones included: answering this
2548                // dialog is as deliberate as a reader gets, and what the last batch refused is
2549                // not a thing to leave sitting beside a live count of this one.
2550                // Each target's own weight, taken from the listing the reader just agreed to
2551                // rather than re-derived from the tree — the same reason the box adds its
2552                // headline up over the entries. A footer whose denominator disagreed with the
2553                // figure in the dialog would be two statements about one batch.
2554                //
2555                // Weighed before any of it goes, because the deleter can only ever report what
2556                // it has *given back*. An unpriced target is a zero, and a batch of nothing but
2557                // those gives no byte figure at all rather than a total that is quietly a
2558                // fraction of the truth.
2559                let weighed: Vec<(PathBuf, u64)> = pending
2560                    .entries
2561                    .iter()
2562                    .filter(|entry| entry.target.is_some())
2563                    .map(|entry| (entry.path.clone(), entry.size.bytes().unwrap_or(0)))
2564                    .collect();
2565                self.removing = Some(Removing::new(&weighed));
2566                self.notice = None;
2567                Effect::Delete(pending.targets)
2568            }
2569        }
2570    }
2571
2572    /// `space` on a line of the confirmation: take that directory out of the batch.
2573    ///
2574    /// **The answer to a surprise has to be better than "cancel and start again".** A reader
2575    /// who reaches this screen and finds something they did not mean to have marked is
2576    /// looking at the one moment where they can still act on it, and a screen that could only
2577    /// be read would send them back to a tree where the offending row may not even be visible.
2578    ///
2579    /// It changes the marks as well as the listing, because the two have to keep saying the
2580    /// same thing: the deed shrinks with the line, and the tree behind the box agrees when
2581    /// the box goes.
2582    fn spare_entry(&mut self) {
2583        let Some(pending) = &self.pending else {
2584            return;
2585        };
2586        let at = pending.at;
2587        let Some(entry) = pending.current().cloned() else {
2588            return;
2589        };
2590        if let Some(pending) = &mut self.pending {
2591            pending.drop_at(at);
2592        }
2593        if let Some(id) = entry.id {
2594            self.unmark(id);
2595        }
2596        self.stale = true;
2597        self.sync();
2598        let left = self
2599            .pending
2600            .as_ref()
2601            .map_or(0, |pending| pending.entries.len());
2602        if left == 0 {
2603            // Nothing left to ask about. Closing is the honest answer rather than a box
2604            // offering to delete an empty set.
2605            self.pending = None;
2606            // Passing, both of these: the reader emptied the batch a line at a time, so this
2607            // reports what they just did rather than something that was refused or failed.
2608            self.says("nothing left in the batch");
2609            return;
2610        }
2611        self.says(format!("{} unmarked", entry.path.display()));
2612    }
2613
2614    /// `x`: hand the marked batch out to be planned.
2615    fn commit(&mut self) -> Effect {
2616        if self.is_deleting() {
2617            self.says("a removal is already running");
2618            return Effect::None;
2619        }
2620        let batch = self.batch();
2621        if batch.is_empty() {
2622            self.says("nothing is marked — space marks a row's whole subtree");
2623            return Effect::None;
2624        }
2625        Effect::Plan(batch)
2626    }
2627
2628    /// Every claim the marks select, which is what a batch is.
2629    ///
2630    /// **The whole selection, and never only the visible part.** A mark resolves through the
2631    /// lens it was *made* through, so narrowing the view afterwards takes nothing out of the
2632    /// batch — anything else would contradict the one promise the model makes, that toggling
2633    /// what is visible never changes what is selected. What the narrowing does instead is put
2634    /// entries on the confirmation marked as hidden, which is where a reader can act on the
2635    /// surprise.
2636    ///
2637    /// A row that is draining away is out. It is on screen for another third of a second
2638    /// saying what happened to it, and offering a directory that is already gone to a second
2639    /// removal would report a failure for a target the first removal succeeded on. That is
2640    /// decided in [`tally`], with the counter, so the two cannot part company.
2641    #[must_use]
2642    pub fn batch(&self) -> Vec<Target> {
2643        let mut batch: Vec<Target> = self
2644            .selection
2645            .iter()
2646            .filter_map(|&id| self.tree.node(id).hit.as_ref().map(Target::from))
2647            .collect();
2648        batch.sort_by(|a, b| a.path.cmp(&b.path));
2649        batch
2650    }
2651
2652    fn resort(&mut self, sort: Sort) {
2653        self.sort = sort;
2654        self.sorted = false;
2655        self.stale = true;
2656    }
2657
2658    /// A digit, or a click on a column heading — order the levels by that key.
2659    ///
2660    /// One method for both doors, so an ordering cannot become the one in force two ways.
2661    /// Naming the order **already in force** turns it upside down, which is what a reader
2662    /// clicking a heading twice means and what `1 1` should therefore mean too.
2663    ///
2664    /// **A new column starts in its own natural order**, rather than inheriting whichever way
2665    /// `S` last left things. The natural order is the useful one in every case — biggest
2666    /// subtree first, names A–Z, stalest first — and carrying a reversal across a column
2667    /// change would be reversing something the reader never asked to reverse.
2668    fn sort_by(&mut self, order: Order) {
2669        self.resort(if self.sort.by == order {
2670            Sort {
2671                by: order,
2672                reverse: !self.sort.reverse,
2673            }
2674        } else {
2675            Sort::by(order)
2676        });
2677    }
2678
2679    // ---- what a pointer does ----------------------------------------------------------
2680
2681    /// A click on a row — put the cursor on that **directory**.
2682    ///
2683    /// Selecting by identity rather than by the index the press resolved to, which is the
2684    /// rule the whole pointer model turns on: rows re-sort as prices land and vanish as
2685    /// removals finish, so an index taken at the press and acted on at the release names
2686    /// somebody else. A directory that is no longer on screen leaves the cursor where it is
2687    /// and says so by returning `false` — the honest outcome, since the row the reader aimed
2688    /// at is gone.
2689    fn point_at(&mut self, id: NodeId) -> bool {
2690        let Some(at) = self.rows.iter().position(|row| row.id == id) else {
2691            return false;
2692        };
2693        self.cursor = Some(at);
2694        self.deselected = false;
2695        self.follow_cursor();
2696        true
2697    }
2698
2699    /// A click on a row's `▸` — select it, and open or close it.
2700    ///
2701    /// The two steps `→` and `←` produce between them, reached with one gesture. A leaf is
2702    /// selected and nothing else happens: there is nothing to open, and the cell its
2703    /// indicator would be in is blank, so a press there cannot have been aimed at one.
2704    fn open_row(&mut self, id: NodeId) {
2705        if !self.point_at(id) || self.tree.children(id).is_empty() {
2706            return;
2707        }
2708        if !self.expanded.insert(id) {
2709            self.expanded.remove(&id);
2710        }
2711        self.stale = true;
2712    }
2713
2714    /// A click on a row's `[ ]` — select it, and mark its subtree or unmark it.
2715    fn mark_row(&mut self, id: NodeId) {
2716        if self.point_at(id) {
2717            self.mark_at(id);
2718        }
2719    }
2720
2721    /// A double click on a row — ask for a price on everything under it that has none.
2722    ///
2723    /// The gesture for the expensive action a reader wants on one specific thing. Under
2724    /// `--breakdown-under` every row outside the named scope reads as a dash, and this is how
2725    /// one of them is asked about without re-running the scan; on a fully priced tree it
2726    /// finds nothing and says so rather than starting work with no result.
2727    ///
2728    /// Two things it refuses, and both are about work rather than about display. A row that
2729    /// is **no longer on screen** starts nothing: a detached node keeps its hit, so walking
2730    /// what is under one would hand back a path the reader can no longer see and the deleter
2731    /// has already removed — the identity rule running the other way round, since here the
2732    /// vanished target costs a traversal rather than a selection. And a claim **already being
2733    /// priced** is not asked for again: `Tree::price` rejects a duplicate result, but only
2734    /// after the expensive part has happened, so leaning on the button during a traversal of
2735    /// a real `node_modules` would queue that traversal over and over.
2736    fn price_row(&mut self, id: NodeId) -> Effect {
2737        if !self.point_at(id) {
2738            return Effect::None;
2739        }
2740        let (waiting, running): (Vec<PathBuf>, Vec<PathBuf>) = self
2741            .unpriced_under(id)
2742            .into_iter()
2743            .partition(|path| !self.pricing.contains(path));
2744        if waiting.is_empty() {
2745            // Two different facts, and the reader can act on the difference: one says there
2746            // is nothing to learn here, the other says to wait.
2747            if running.is_empty() {
2748                self.says("everything under here already carries a price");
2749            } else {
2750                self.says(format!(
2751                    "{} under here is already being priced",
2752                    plural(running.len(), "directory", "directories")
2753                ));
2754            }
2755            return Effect::None;
2756        }
2757        self.pricing.extend(waiting.iter().cloned());
2758        self.says(format!(
2759            "pricing {}…",
2760            plural(waiting.len(), "directory", "directories")
2761        ));
2762        Effect::Price(waiting)
2763    }
2764
2765    /// Every claim under `id` that nobody has put a number on.
2766    ///
2767    /// Filtered, for [`View::batch`]'s reason: what a row acts on is what its own number
2768    /// describes, and a gesture that priced claims the filter is hiding would move a total
2769    /// the reader cannot see.
2770    fn unpriced_under(&self, id: NodeId) -> Vec<PathBuf> {
2771        let mut found = Vec::new();
2772        let mut stack = vec![id];
2773        while let Some(id) = stack.pop() {
2774            if !self.shown(id) {
2775                continue;
2776            }
2777            let node = self.tree.node(id);
2778            match &node.hit {
2779                Some(hit) if hit.size.bytes().is_none() => found.push(hit.path.clone()),
2780                Some(_) => {}
2781                None => stack.extend(node.children.iter().copied()),
2782            }
2783        }
2784        found.sort();
2785        found
2786    }
2787
2788    /// The wheel — move the viewport, and take the cursor with it.
2789    ///
2790    /// pua leaves its cursor behind when the wheel moves its tree, because there a cursor only
2791    /// highlights. Here it is what `space`, `→`, `←` and `*` act on, so a cursor scrolled off
2792    /// the screen is a mark aimed at a row nobody can see — and [`View::follow_cursor`] would
2793    /// drag the viewport back to it on the next frame anyway. It is pushed to the nearest row
2794    /// still drawn instead, which is where a reader who scrolled to look at something would
2795    /// have put it. A cursor that was taken away stays away: scrolling is not choosing.
2796    fn scroll_rows(&mut self, motion: Motion) {
2797        if self.rows.is_empty() {
2798            return;
2799        }
2800        let last = self.rows.len() - 1;
2801        // Never past the point where the last row is at the bottom of the pane. The cursor
2802        // keys reach that same limit through `follow_cursor`, which stops the moment the
2803        // cursor is on screen; a wheel has no cursor pulling it up, so the limit is its own.
2804        let furthest = self.rows.len().saturating_sub(self.page);
2805        self.scroll = match motion {
2806            Motion::Up => self.scroll.saturating_sub(WHEEL),
2807            Motion::Down => (self.scroll + WHEEL).min(furthest),
2808            Motion::PageUp => self.scroll.saturating_sub(self.page),
2809            Motion::PageDown => (self.scroll + self.page).min(furthest),
2810            Motion::Top => 0,
2811            Motion::Bottom => furthest,
2812        };
2813        if let Some(at) = self.cursor {
2814            self.cursor = Some(at.clamp(self.scroll, (self.scroll + self.page - 1).min(last)));
2815        }
2816    }
2817
2818    // ---- the cursor -------------------------------------------------------------------
2819
2820    fn move_cursor(&mut self, motion: Motion) {
2821        if self.rows.is_empty() {
2822            return;
2823        }
2824        let last = self.rows.len() - 1;
2825        let at = match self.cursor {
2826            // A view whose cursor was taken away picks up again at whichever end the key was
2827            // reaching for, deliberately: the reader is choosing a row rather than inheriting
2828            // one.
2829            None => match motion {
2830                Motion::Up | Motion::PageUp | Motion::Bottom => last,
2831                Motion::Down | Motion::PageDown | Motion::Top => 0,
2832            },
2833            Some(at) => match motion {
2834                Motion::Up => at.saturating_sub(1),
2835                Motion::Down => (at + 1).min(last),
2836                Motion::PageUp => at.saturating_sub(self.page),
2837                Motion::PageDown => (at + self.page).min(last),
2838                Motion::Top => 0,
2839                Motion::Bottom => last,
2840            },
2841        };
2842        self.cursor = Some(at);
2843        self.deselected = false;
2844        self.follow_cursor();
2845    }
2846
2847    /// The row under the cursor.
2848    #[must_use]
2849    pub fn row(&self) -> Option<Row> {
2850        self.cursor.and_then(|at| self.rows.get(at).copied())
2851    }
2852
2853    /// The cursor's directory and every directory above it, nearest first.
2854    ///
2855    /// The chain rather than just the row, because between one frame and the next the
2856    /// directory under the cursor can simply be gone. Walking outwards then lands on the
2857    /// nearest surviving ancestor — which is almost always what the reader was working on —
2858    /// instead of on row 0.
2859    fn anchor(&self) -> Vec<PathBuf> {
2860        let mut chain = Vec::new();
2861        let Some(row) = self.row() else {
2862            return chain;
2863        };
2864        let mut at = Some(row.id);
2865        while let Some(id) = at {
2866            let node = self.tree.node(id);
2867            chain.push(node.path.clone());
2868            at = node.parent;
2869        }
2870        chain
2871    }
2872
2873    /// Puts the cursor on the first path of `chain` that is on screen, or nowhere.
2874    fn settle(&mut self, chain: &[PathBuf]) {
2875        self.cursor = chain.iter().find_map(|path| {
2876            self.rows
2877                .iter()
2878                .position(|row| self.tree.node(row.id).path == *path)
2879        });
2880        if self.cursor.is_none() {
2881            if chain.is_empty() && !self.deselected && !self.rows.is_empty() {
2882                // Nothing was selected because nothing had arrived yet. The first rows to
2883                // land get the cursor, so the view is usable without a keystroke to wake it.
2884                self.cursor = Some(0);
2885            } else if !chain.is_empty() {
2886                // Everything the reader was looking at has been deleted. Deselecting is
2887                // visible; clamping the old index would silently hand the next keystroke to
2888                // whatever fell into that position.
2889                self.deselected = true;
2890            }
2891        }
2892    }
2893
2894    /// Keeps the viewport over the cursor, and inside the rows either way.
2895    ///
2896    /// The clamp runs even with no cursor, which is not belt-and-braces: a filter can take every
2897    /// row away while the viewport is a long way down, and a scroll offset past the end of the
2898    /// rows draws an empty pane over a tree that has plenty in it.
2899    fn follow_cursor(&mut self) {
2900        if let Some(at) = self.cursor {
2901            if at < self.scroll {
2902                self.scroll = at;
2903            } else if at >= self.scroll + self.page {
2904                self.scroll = at + 1 - self.page;
2905            }
2906        }
2907        self.scroll = self.scroll.min(self.rows.len().saturating_sub(1));
2908    }
2909
2910    // ---- opening and closing ----------------------------------------------------------
2911
2912    /// `→`: open a closed row, or step into an open one.
2913    fn expand(&mut self) {
2914        let Some(row) = self.row() else {
2915            return;
2916        };
2917        if self.tree.children(row.id).is_empty() {
2918            return;
2919        }
2920        if self.expanded.insert(row.id) {
2921            self.stale = true;
2922        } else if self.cursor.is_some_and(|at| at + 1 < self.rows.len()) {
2923            self.move_cursor(Motion::Down);
2924        }
2925    }
2926
2927    /// `←`: close an open row, or step out of a closed one.
2928    fn collapse(&mut self) {
2929        let Some(row) = self.row() else {
2930            return;
2931        };
2932        if self.expanded.remove(&row.id) {
2933            self.stale = true;
2934        } else if let Some(parent) = self.tree.node(row.id).parent {
2935            let path = self.tree.node(parent).path.clone();
2936            if let Some(at) = self
2937                .rows
2938                .iter()
2939                .position(|row| self.tree.node(row.id).path == path)
2940            {
2941                self.cursor = Some(at);
2942                self.deselected = false;
2943                self.follow_cursor();
2944            }
2945        }
2946    }
2947
2948    /// `*`: open or close everything under the cursor.
2949    fn toggle_subtree(&mut self) {
2950        let Some(row) = self.row() else {
2951            return;
2952        };
2953        let opening = !self.expanded.contains(&row.id);
2954        let mut stack = vec![row.id];
2955        while let Some(id) = stack.pop() {
2956            if self.tree.children(id).is_empty() {
2957                continue;
2958            }
2959            if opening {
2960                self.expanded.insert(id);
2961            } else {
2962                self.expanded.remove(&id);
2963            }
2964            stack.extend(self.tree.children(id).iter().copied());
2965        }
2966        self.stale = true;
2967    }
2968
2969    /// Walks the open rows into a flat list.
2970    fn reflatten(&mut self) {
2971        self.rows.clear();
2972        let mut stack = vec![(self.tree.root(), 0usize)];
2973        while let Some((id, depth)) = stack.pop() {
2974            if !self.shown(id) {
2975                continue;
2976            }
2977            self.rows.push(Row { id, depth });
2978            if self.expanded.contains(&id) {
2979                // Reversed, because a stack hands back what went in last and the levels are
2980                // already in the order the sort put them.
2981                for &child in self.tree.children(id).iter().rev() {
2982                    stack.push((child, depth + 1));
2983                }
2984            }
2985        }
2986    }
2987
2988    /// Whether a node survives the current view. Everything survives when it hides nothing.
2989    ///
2990    /// The one exception is the scan root of a tree with nothing in it yet, and it earns its
2991    /// place now that the view a run opens on **narrows**: the root is the directory the reader
2992    /// typed rather than a claim, so a view has nothing to say about it, and hiding it would
2993    /// blank the pane for the first moments of every run and for the whole of a scan that finds
2994    /// only the tier `default` leaves out. A filter that matches nothing still empties the pane
2995    /// — that is a narrowing the reader asked for, and #602's deselection depends on it.
2996    fn shown(&self, id: NodeId) -> bool {
2997        if !self.is_sifted() {
2998            return true;
2999        }
3000        if id == self.tree.root() && self.tree.node(id).claims == 0 {
3001            return true;
3002        }
3003        self.roll(id).claims > 0
3004    }
3005
3006    // ---- marking ----------------------------------------------------------------------
3007
3008    /// `space`: mark the cursor's subtree, or unmark it.
3009    ///
3010    /// A mark runs visibly up the ancestors on its way in — see [`Moving::cascade`]. It is the
3011    /// signature interaction and the one whose effect is otherwise entirely off screen: a mark
3012    /// on a collapsed row takes everything underneath, and the only place that shows is on
3013    /// ancestors the reader is not looking at. The cascade is that fact, drawn.
3014    fn toggle_mark(&mut self) {
3015        if let Some(row) = self.row() {
3016            self.mark_at(row.id);
3017        }
3018    }
3019
3020    /// Marking one row, whichever door reached it — the key or the box under the pointer.
3021    ///
3022    /// Both the guard and the cascade live here rather than in [`View::toggle_mark`], because
3023    /// they are facts about marking a row and not about the key that reached it: a press on
3024    /// the mark box of a row the deleter is emptying has to be refused for exactly the reason
3025    /// `space` on it is.
3026    fn mark_at(&mut self, id: NodeId) {
3027        // A directory the deleter has already finished with is not something to mark for
3028        // deletion. Its row is still on screen because it is emptying, which is a statement
3029        // about the past.
3030        if self.is_leaving(id) {
3031            return;
3032        }
3033        // What the *reader* can see is what the key toggles, which is why this asks the
3034        // filter-relative glyph rather than a global "is it covered": a row drawn full is a
3035        // row `space` empties, and a row drawn part-full is one it fills.
3036        if self.mark_of(id) == Mark::All {
3037            self.unmark(id);
3038        } else {
3039            let chain = self.ancestry(id);
3040            self.moving.cascade(&chain, self.now);
3041            self.mark(id);
3042        }
3043    }
3044
3045    /// A row and every directory above it, nearest first — what a cascade runs through.
3046    fn ancestry(&self, id: NodeId) -> Vec<NodeId> {
3047        let mut chain = Vec::new();
3048        let mut at = Some(id);
3049        while let Some(current) = at {
3050            chain.push(current);
3051            at = self.tree.node(current).parent;
3052        }
3053        chain
3054    }
3055
3056    /// `a`: mark everything, or — if anything at all is marked — clear.
3057    fn mark_all(&mut self) {
3058        if self.marks.is_empty() {
3059            self.mark(self.tree.root());
3060        } else {
3061            self.clear_marks();
3062        }
3063    }
3064
3065    /// Marks a subtree, as the current view defines it.
3066    ///
3067    /// The lens is copied into the mark rather than referred to, which is what makes a later
3068    /// change of view inert: `~/repos` marked under `dependencies` keeps meaning the
3069    /// dependencies under `~/repos` when the reader widens to `all`, and the build artefacts
3070    /// beside them stay unmarked.
3071    ///
3072    /// Two tidyings, and neither of them ever loses a selection. Any exclusion at or under
3073    /// the new mark goes, because the reader has just said to take the lot; and a mark
3074    /// already inside it **through the same lens** is absorbed, because it now says nothing
3075    /// the outer one does not. A mark inside it through a *different* lens survives, since it
3076    /// may well cover claims this one does not.
3077    fn mark(&mut self, id: NodeId) {
3078        self.mark_stamp += 1;
3079        let lens = self.lens.clone();
3080        let inside: Vec<NodeId> = self
3081            .spared
3082            .iter()
3083            .copied()
3084            .filter(|&spared| self.descends_from(spared, id))
3085            .collect();
3086        for spared in inside {
3087            self.spared.remove(&spared);
3088        }
3089        let tree = &self.tree;
3090        self.marks.retain(|mark| {
3091            !(mark.lens == lens && mark.root != id && descends_from(tree, mark.root, id))
3092        });
3093        if !self
3094            .marks
3095            .iter()
3096            .any(|mark| mark.root == id && mark.lens == lens)
3097        {
3098            self.marks.push(Marked { root: id, lens });
3099        }
3100        self.stale = true;
3101    }
3102
3103    /// Unmarks a subtree, whichever mark was covering it.
3104    ///
3105    /// A mark rooted here goes outright; anything else is an ancestor's mark reaching down,
3106    /// and what spares this subtree from it is an **exclusion** rather than a mark on every
3107    /// sibling along the path. The push-down that used to do this cost a mark per sibling on
3108    /// a level 8,660 wide, and — worse — it was a statement about the claims that existed at
3109    /// that instant, so a claim arriving next to a spared row a minute later was silently
3110    /// unmarked too.
3111    fn unmark(&mut self, id: NodeId) {
3112        self.mark_stamp += 1;
3113        self.marks.retain(|mark| mark.root != id);
3114        // **Re-derived before the next question rather than after this one.** The counts on
3115        // hand describe the state before the line above, so asking them whether anything is
3116        // still covering this row would answer about the mark that has just gone — and the
3117        // answer decides whether an exclusion is left behind. A stray exclusion is invisible
3118        // and outlives the keystroke that produced it: the next mark on an ancestor would
3119        // quietly spare a subtree nobody spared.
3120        self.stale = true;
3121        self.sync();
3122        if self.mark_of(id) == Mark::None && !self.selects_anything_under(id) {
3123            return;
3124        }
3125        // Still covered from above, so it is spared rather than unmarked — and every mark
3126        // that lived inside it goes with it, since nothing under an exclusion is selected.
3127        let tree = &self.tree;
3128        self.marks
3129            .retain(|mark| !descends_from(tree, mark.root, id));
3130        self.spared.insert(id);
3131        self.stale = true;
3132    }
3133
3134    /// Whether anything at all under this node is selected, visible or not.
3135    ///
3136    /// The glyph cannot answer this on its own: a subtree whose every selected claim is
3137    /// hidden draws as unmarked, and it still has to be sparable — that is the whole hazard
3138    /// the confirmation screen exists for, reached from the tree instead.
3139    fn selects_anything_under(&self, id: NodeId) -> bool {
3140        self.counts
3141            .get(id)
3142            .is_some_and(|counts| counts.all.claims > 0)
3143    }
3144
3145    fn clear_marks(&mut self) {
3146        if !self.marks.is_empty() || !self.spared.is_empty() {
3147            self.mark_stamp += 1;
3148        }
3149        self.marks.clear();
3150        self.spared.clear();
3151        self.counts.clear();
3152        self.selection.clear();
3153        self.map_stamps.clear();
3154        self.stale = true;
3155    }
3156
3157    /// Rebuilds everything the lens and the marks decide, in one pass. See [`tally`].
3158    ///
3159    /// Skipped entirely on the view a run opens with — nothing marked and nothing hidden —
3160    /// so a reader watching a scan of a home directory pays for none of it. From the first
3161    /// mark on it is one traversal per frame, which is the price of a glyph that is
3162    /// **filter-relative**: an ancestor can be fully marked under `dependencies` and partly
3163    /// marked under `all`, and a glyph computed globally would contradict what the reader can
3164    /// see. The alternative — a subtree walk per row per frame — is what the cache the old
3165    /// `below` map existed for was already avoiding, and this keeps that shape rather than
3166    /// giving it up.
3167    fn recount(&mut self) {
3168        self.counts.clear();
3169        self.selection.clear();
3170        self.map_stamps.clear();
3171        if !self.is_sifted() && self.marks.is_empty() {
3172            return;
3173        }
3174        let mut out = Tallied {
3175            counts: std::mem::take(&mut self.counts),
3176            selection: std::mem::take(&mut self.selection),
3177            map_stamps: std::mem::take(&mut self.map_stamps),
3178        };
3179        // The stamps are sized only when there is a map to draw, because the map is the only
3180        // thing that asks and most terminals never have one. Left empty is how [`tally`] is
3181        // told not to fold them — see [`View::map_stamp`] for what a run without one falls
3182        // back to.
3183        if self.maps() {
3184            out.map_stamps.resize(self.tree.minted(), 0);
3185        }
3186        tally(
3187            &self.tree,
3188            &self.lens,
3189            &self.marks,
3190            &self.spared,
3191            &self.moving,
3192            &mut out,
3193        );
3194        self.counts = out.counts;
3195        self.selection = out.selection;
3196        self.map_stamps = out.map_stamps;
3197    }
3198
3199    /// Whether the lens is hiding anything at all.
3200    fn is_sifted(&self) -> bool {
3201        !self.lens.is_everything()
3202    }
3203
3204    /// Whether `id` is at or under `root`.
3205    fn descends_from(&self, id: NodeId, root: NodeId) -> bool {
3206        descends_from(&self.tree, id, root)
3207    }
3208}
3209
3210/// Whether `id` is at or under `root`.
3211///
3212/// Walked upwards from the node rather than downwards from the root, because a chain is a
3213/// handful of steps and a subtree can be most of the tree. A free function so that it can be
3214/// asked while a `retain` holds the field it would otherwise be a method on.
3215fn descends_from(tree: &Tree, id: NodeId, root: NodeId) -> bool {
3216    let mut at = Some(id);
3217    while let Some(current) = at {
3218        if current == root {
3219            return true;
3220        }
3221        at = tree.node(current).parent;
3222    }
3223    false
3224}
3225
3226/// Where a kind sorts in the listing, with the unnamed tier last.
3227///
3228/// Last rather than first deliberately: the groups a reader recognises come before the group
3229/// that says only that git knows about it, which is the one they will want to read most
3230/// carefully and so the one that should not be scrolled past on the way in.
3231fn kind_order(kind: Option<Kind>) -> usize {
3232    // Read off the vocabulary's own cost ordering rather than written out again, so the
3233    // confirmation groups the expensive end first without this file having a second opinion
3234    // about which end that is. A claim nothing named sorts last, after every kind.
3235    kind.map_or(Kind::ALL.len(), Kind::cost)
3236}
3237
3238/// `1 directory`, `4 directories`.
3239#[must_use]
3240pub fn plural(count: usize, one: &str, many: &str) -> String {
3241    format!("{count} {}", if count == 1 { one } else { many })
3242}
3243
3244#[cfg(test)]
3245mod tests {
3246    use super::{
3247        Action, Answer, Effect, Maps, Mark, Motion, Notice, Overlay, Planned, Preset, Turn, View,
3248    };
3249    use crate::delete::{Refusal, Refused};
3250    use crate::fixture::{gitignored, gitignored_file, hit, of_kind};
3251    use crate::rules::Kind;
3252    use crate::size::Size;
3253    use crate::tree::{Order, Sort, Tree};
3254    use crate::tui::moving::{ARRIVAL, COUNT_UP, DIM, FLASH, RUNG};
3255    use std::path::{Path, PathBuf};
3256    use std::time::{Duration, Instant};
3257
3258    /// A view over a fixed little tree:
3259    ///
3260    /// ```text
3261    /// /scan
3262    ///   nx           300
3263    ///     node_modules  200
3264    ///     packages
3265    ///       ui/node_modules  100
3266    ///   old          10
3267    ///     target        10
3268    /// ```
3269    fn view() -> View {
3270        let mut tree = Tree::new("/scan");
3271        tree.insert(hit("/scan/nx/node_modules", Size::Measured(200), 900));
3272        tree.insert(hit(
3273            "/scan/nx/packages/ui/node_modules",
3274            Size::Measured(100),
3275            800,
3276        ));
3277        tree.insert(hit("/scan/old/target", Size::Measured(10), 100));
3278        let mut view = View::new(tree);
3279        view.viewport(40);
3280        view
3281    }
3282
3283    /// Every visible row, as `<indent><name>`.
3284    fn shown(view: &View) -> Vec<String> {
3285        view.rows()
3286            .iter()
3287            .map(|row| {
3288                format!(
3289                    "{}{}",
3290                    "  ".repeat(row.depth),
3291                    view.tree().node(row.id).name.to_string_lossy()
3292                )
3293            })
3294            .collect()
3295    }
3296
3297    fn at(view: &View, path: &str) -> crate::tree::NodeId {
3298        view.tree().find(Path::new(path)).unwrap()
3299    }
3300
3301    /// Runs the clock past the drain, which is what actually takes a removed row out of the
3302    /// tree. Every test written before the drain existed used a bare `sync` for this, and the
3303    /// substitution is exact: the removal still happens, a third of a second later.
3304    fn settle(view: &mut View) {
3305        view.animate(Instant::now() + DIM * 2);
3306    }
3307
3308    /// Moves the cursor onto a row that is already visible, using only the keys a reader has.
3309    fn select(view: &mut View, path: &str) {
3310        let want = PathBuf::from(path);
3311        let at = view
3312            .rows()
3313            .iter()
3314            .position(|row| view.tree().node(row.id).path == want)
3315            .unwrap_or_else(|| panic!("{path} is not on screen"));
3316        view.apply(Action::Cursor(Motion::Top));
3317        for _ in 0..at {
3318            view.apply(Action::Cursor(Motion::Down));
3319        }
3320    }
3321
3322    /// Opens every directory on the way to `path` and puts the cursor on it.
3323    ///
3324    /// The target itself is left exactly as it was, opened or closed, because half these
3325    /// tests are about what a key does to a *collapsed* row.
3326    fn point_at(view: &mut View, path: &str) {
3327        let mut above = PathBuf::from("/scan");
3328        let below = Path::new(path).strip_prefix("/scan").unwrap().to_path_buf();
3329        for component in below.components() {
3330            let at = view.tree().find(&above).unwrap();
3331            if !view.is_expanded(at) {
3332                select(view, &above.to_string_lossy());
3333                view.apply(Action::Expand);
3334            }
3335            above.push(component);
3336        }
3337        select(view, path);
3338    }
3339
3340    // ---- the tree itself --------------------------------------------------------------
3341
3342    #[test]
3343    fn everything_but_the_root_starts_closed() {
3344        let view = view();
3345        assert_eq!(shown(&view), ["/scan", "  nx", "  old"]);
3346    }
3347
3348    #[test]
3349    fn a_row_opens_onto_its_own_children_only() {
3350        let mut view = view();
3351        point_at(&mut view, "/scan/nx");
3352        view.apply(Action::Expand);
3353        assert_eq!(
3354            shown(&view),
3355            ["/scan", "  nx", "    node_modules", "    packages", "  old"]
3356        );
3357    }
3358
3359    #[test]
3360    fn opening_an_open_row_steps_into_it_and_closing_a_closed_one_steps_out() {
3361        let mut view = view();
3362        point_at(&mut view, "/scan/nx");
3363        view.apply(Action::Expand);
3364        view.apply(Action::Expand);
3365        assert_eq!(
3366            view.tree().node(view.row().unwrap().id).path,
3367            PathBuf::from("/scan/nx/node_modules")
3368        );
3369        view.apply(Action::Collapse);
3370        assert_eq!(
3371            view.tree().node(view.row().unwrap().id).path,
3372            PathBuf::from("/scan/nx")
3373        );
3374    }
3375
3376    #[test]
3377    fn the_star_key_opens_a_whole_subtree_and_z_closes_everything() {
3378        let mut view = view();
3379        point_at(&mut view, "/scan/nx");
3380        view.apply(Action::ToggleSubtree);
3381        assert_eq!(
3382            shown(&view),
3383            [
3384                "/scan",
3385                "  nx",
3386                "    node_modules",
3387                "    packages",
3388                "      ui",
3389                "        node_modules",
3390                "  old",
3391            ]
3392        );
3393        view.apply(Action::CollapseAll);
3394        assert_eq!(shown(&view), ["/scan", "  nx", "  old"]);
3395    }
3396
3397    #[test]
3398    fn levels_sort_within_themselves_rather_than_globally() {
3399        let mut view = view();
3400        point_at(&mut view, "/scan/nx");
3401        view.apply(Action::ToggleSubtree);
3402        view.apply(Action::SortBy(Order::Path));
3403        assert_eq!(view.sort(), Sort::by(Order::Path));
3404        // `old` sorts after `nx` at the top level and stays there; the 100-byte
3405        // `ui/node_modules` stays under `packages` rather than sorting among the roots.
3406        assert_eq!(
3407            shown(&view),
3408            [
3409                "/scan",
3410                "  nx",
3411                "    node_modules",
3412                "    packages",
3413                "      ui",
3414                "        node_modules",
3415                "  old",
3416            ]
3417        );
3418    }
3419
3420    #[test]
3421    fn a_row_that_has_not_been_priced_reads_as_unpriced_rather_than_as_empty() {
3422        let mut tree = Tree::new("/scan");
3423        tree.insert(hit("/scan/nx/node_modules", Size::Unmeasured, 900));
3424        let view = View::new(tree);
3425
3426        let roll = view.roll(at(&view, "/scan/nx"));
3427        assert_eq!(roll.bytes, 0);
3428        assert_eq!(roll.unpriced, 1);
3429        assert_eq!(roll.label(), "—");
3430
3431        // …and the moment a price lands it is a number, without the row moving.
3432        let mut view = view;
3433        view.priced(Path::new("/scan/nx/node_modules"), Size::Measured(2048));
3434        view.sync();
3435        assert_eq!(view.roll(at(&view, "/scan/nx")).label(), "2.0 KiB");
3436    }
3437
3438    // ---- streaming --------------------------------------------------------------------
3439
3440    #[test]
3441    fn a_claim_that_arrives_under_a_closed_row_moves_its_total_and_not_the_cursor() {
3442        let mut view = view();
3443        point_at(&mut view, "/scan/old");
3444        let before = view.total().bytes;
3445
3446        view.found(hit(
3447            "/scan/nx/packages/api/node_modules",
3448            Size::Measured(5),
3449            1,
3450        ));
3451        view.sync();
3452
3453        assert_eq!(view.total().bytes, before + 5);
3454        assert_eq!(
3455            view.tree().node(view.row().unwrap().id).path,
3456            PathBuf::from("/scan/old"),
3457            "an arrival elsewhere moved the cursor"
3458        );
3459    }
3460
3461    #[test]
3462    fn a_claim_that_arrives_under_a_marked_row_is_marked_on_arrival() {
3463        let mut view = view();
3464        point_at(&mut view, "/scan/nx");
3465        view.apply(Action::Mark);
3466        assert_eq!(view.marked().claims, 2);
3467
3468        view.found(hit(
3469            "/scan/nx/packages/api/node_modules",
3470            Size::Measured(5),
3471            1,
3472        ));
3473        view.sync();
3474
3475        // The reason a mark is a subtree root rather than the set of rows it covered: the
3476        // reader said "everything under nx", and the scan is still finding what that is.
3477        assert_eq!(view.marked().claims, 3);
3478        assert_eq!(view.marked().bytes, 305);
3479        assert!(batched(&view).contains(&PathBuf::from("/scan/nx/packages/api/node_modules")));
3480    }
3481
3482    // ---- the cursor -------------------------------------------------------------------
3483
3484    #[test]
3485    fn the_cursor_stays_on_its_directory_when_a_price_re_sorts_the_level_under_it() {
3486        let mut tree = Tree::new("/scan");
3487        tree.insert(hit("/scan/nx/node_modules", Size::Measured(200), 900));
3488        tree.insert(hit("/scan/old/target", Size::Unmeasured, 100));
3489        let mut view = View::new(tree);
3490        view.viewport(40);
3491        point_at(&mut view, "/scan/old");
3492        assert_eq!(view.cursor(), Some(2));
3493
3494        // The claim arrived unpriced, as every tier-one claim does, and the pool has just
3495        // put a number on it that makes it the biggest thing in the scan. The row moves, by
3496        // design — and the cursor moves with it rather than staying on row 2, which is now
3497        // somebody else.
3498        view.priced(Path::new("/scan/old/target"), Size::Measured(9000));
3499        view.sync();
3500
3501        assert_eq!(shown(&view), ["/scan", "  old", "  nx"]);
3502        assert_eq!(
3503            view.tree().node(view.row().unwrap().id).path,
3504            PathBuf::from("/scan/old")
3505        );
3506        assert_eq!(view.cursor(), Some(1));
3507    }
3508
3509    #[test]
3510    fn a_deleted_row_leaves_the_cursor_on_the_nearest_directory_that_is_still_there() {
3511        let mut view = view();
3512        point_at(&mut view, "/scan/nx");
3513        view.apply(Action::ToggleSubtree);
3514        point_at(&mut view, "/scan/nx/packages/ui/node_modules");
3515
3516        view.removed(Path::new("/scan/nx/packages/ui/node_modules"), 100, true);
3517        settle(&mut view);
3518
3519        // `ui` and `packages` held nothing else, so they went too. `nx` is what is left of
3520        // where the reader was — and it is emphatically not row 0, which is the scan root.
3521        assert_eq!(
3522            view.tree().node(view.row().unwrap().id).path,
3523            PathBuf::from("/scan/nx")
3524        );
3525    }
3526
3527    #[test]
3528    fn deleting_everything_a_reader_was_looking_at_lands_on_the_scan_root_and_not_on_a_stranger() {
3529        let mut view = view();
3530        point_at(&mut view, "/scan/old");
3531        view.removed(Path::new("/scan/old/target"), 10, true);
3532        settle(&mut view);
3533
3534        // The chain ends at the scan root, so that is where a cursor with nothing else left
3535        // above it comes to rest. It is the *nearest surviving ancestor* rather than "row 0":
3536        // the difference shows here, because `nx` is what is at row 1 and the cursor has
3537        // deliberately not been handed it.
3538        assert_eq!(
3539            view.tree().node(view.row().unwrap().id).path,
3540            PathBuf::from("/scan")
3541        );
3542    }
3543
3544    #[test]
3545    fn a_cursor_whose_whole_ancestry_is_off_screen_is_deselected_rather_than_moved_to_row_zero() {
3546        let mut view = view();
3547        point_at(&mut view, "/scan/old");
3548        filter(&mut view, "nothing matches this");
3549
3550        assert!(view.rows().is_empty());
3551        assert_eq!(view.cursor(), None);
3552
3553        // …and it stays deselected once there are rows again, rather than being handed row 0
3554        // by the next frame. Row 0 is the scan root, whose subtree is everything.
3555        view.apply(Action::Back);
3556        assert_eq!(view.cursor(), None);
3557        view.found(hit("/scan/other/node_modules", Size::Measured(1), 1));
3558        view.sync();
3559        assert_eq!(view.cursor(), None);
3560
3561        // A deliberate keystroke is what picks a row again.
3562        view.apply(Action::Cursor(Motion::Down));
3563        assert_eq!(view.cursor(), Some(0));
3564    }
3565
3566    #[test]
3567    fn a_target_the_deleter_could_not_finish_keeps_its_row() {
3568        let mut view = view();
3569        let before = view.total();
3570
3571        view.removed(Path::new("/scan/old/target"), 0, false);
3572        view.sync();
3573
3574        // The sweep went in and came out again — a checkout inside it, an unreadable
3575        // subtree. The directory is still on disk, so a row that vanished would be a lie.
3576        assert_eq!(view.total(), before);
3577        assert!(view.tree().find(Path::new("/scan/old/target")).is_some());
3578    }
3579
3580    // ---- marking ----------------------------------------------------------------------
3581
3582    #[test]
3583    fn marking_a_collapsed_row_marks_everything_beneath_it() {
3584        let mut view = view();
3585        point_at(&mut view, "/scan/nx");
3586        view.apply(Action::Mark);
3587
3588        assert_eq!(view.mark_of(at(&view, "/scan/nx")), Mark::All);
3589        assert_eq!(
3590            view.mark_of(at(&view, "/scan/nx/packages/ui/node_modules")),
3591            Mark::All
3592        );
3593        assert_eq!(view.mark_of(at(&view, "/scan/old/target")), Mark::None);
3594        assert_eq!(
3595            batched(&view),
3596            [
3597                PathBuf::from("/scan/nx/node_modules"),
3598                PathBuf::from("/scan/nx/packages/ui/node_modules"),
3599            ]
3600        );
3601    }
3602
3603    #[test]
3604    fn an_ancestor_of_a_mark_shows_a_partial_state() {
3605        let mut view = view();
3606        point_at(&mut view, "/scan/nx");
3607        view.apply(Action::Expand);
3608        point_at(&mut view, "/scan/nx/node_modules");
3609        view.apply(Action::Mark);
3610
3611        assert_eq!(view.mark_of(at(&view, "/scan/nx")), Mark::Partial);
3612        assert_eq!(view.mark_of(view.tree().root()), Mark::Partial);
3613        assert_eq!(view.mark_of(at(&view, "/scan/old")), Mark::None);
3614    }
3615
3616    #[test]
3617    fn unmarking_one_row_out_of_a_marked_subtree_spares_it_and_keeps_the_rest() {
3618        let mut view = view();
3619        point_at(&mut view, "/scan/nx");
3620        view.apply(Action::Mark);
3621        view.apply(Action::ToggleSubtree);
3622        point_at(&mut view, "/scan/nx/node_modules");
3623        view.apply(Action::Mark);
3624
3625        assert_eq!(view.mark_of(at(&view, "/scan/nx/node_modules")), Mark::None);
3626        assert_eq!(
3627            view.mark_of(at(&view, "/scan/nx/packages/ui/node_modules")),
3628            Mark::All
3629        );
3630        assert_eq!(view.mark_of(at(&view, "/scan/nx")), Mark::Partial);
3631        assert_eq!(
3632            batched(&view),
3633            [PathBuf::from("/scan/nx/packages/ui/node_modules")]
3634        );
3635    }
3636
3637    #[test]
3638    fn marking_a_row_absorbs_the_marks_already_inside_it() {
3639        let mut view = view();
3640        point_at(&mut view, "/scan/nx");
3641        view.apply(Action::ToggleSubtree);
3642        point_at(&mut view, "/scan/nx/node_modules");
3643        view.apply(Action::Mark);
3644        point_at(&mut view, "/scan/nx");
3645        view.apply(Action::Mark);
3646
3647        assert_eq!(view.marked().claims, 2);
3648        assert_eq!(view.marked().bytes, 300);
3649        // …and unmarking the outer one leaves nothing behind, rather than uncovering the
3650        // inner mark it swallowed.
3651        view.apply(Action::Mark);
3652        assert_eq!(view.marked().claims, 0);
3653        assert_eq!(view.mark_of(at(&view, "/scan/nx/node_modules")), Mark::None);
3654    }
3655
3656    #[test]
3657    fn a_key_marks_everything_and_the_same_key_clears_a_partial_selection() {
3658        let mut view = view();
3659        view.apply(Action::MarkAll);
3660        assert_eq!(view.marked().claims, 3);
3661        assert_eq!(view.marked().bytes, 310);
3662
3663        view.apply(Action::MarkAll);
3664        assert_eq!(view.marked().claims, 0);
3665
3666        // A partial selection clears rather than growing: a reader who has marked forty
3667        // directories can afford to lose the selection and cannot afford to gain thirty more.
3668        point_at(&mut view, "/scan/old");
3669        view.apply(Action::Mark);
3670        view.apply(Action::MarkAll);
3671        assert_eq!(view.marked().claims, 0);
3672    }
3673
3674    #[test]
3675    fn a_mark_on_a_directory_the_deleter_has_taken_away_stops_counting() {
3676        let mut view = view();
3677        point_at(&mut view, "/scan/old");
3678        view.apply(Action::Mark);
3679        assert_eq!(view.marked().claims, 1);
3680
3681        view.removed(Path::new("/scan/old/target"), 10, true);
3682        settle(&mut view);
3683
3684        assert_eq!(view.marked().claims, 0);
3685        assert!(batched(&view).is_empty());
3686        assert_eq!(view.mark_of(view.tree().root()), Mark::None);
3687    }
3688
3689    // ---- what is visible, and what that has to do with what is selected -----------------
3690
3691    /// A view over a tree with one of each kind, plus a claim only git knows about:
3692    ///
3693    /// ```text
3694    /// /scan
3695    ///   nx
3696    ///     node_modules   200   Dependencies
3697    ///     dist           100   Build
3698    ///     .nx/cache       10   Cache
3699    ///     out              1   gitignored, kind unknown
3700    ///   old
3701    ///     target          20   Build
3702    /// ```
3703    fn mixed() -> View {
3704        let mut tree = Tree::new("/scan");
3705        tree.insert(sized(
3706            of_kind("/scan/nx/node_modules", Kind::Dependencies),
3707            200,
3708        ));
3709        tree.insert(sized(of_kind("/scan/nx/dist", Kind::Build), 100));
3710        tree.insert(sized(of_kind("/scan/nx/.nx/cache", Kind::Cache), 10));
3711        tree.insert(sized(gitignored("/scan/nx/out"), 1));
3712        tree.insert(sized(of_kind("/scan/old/target", Kind::Build), 20));
3713        let mut view = View::new(tree);
3714        view.viewport(40);
3715        view
3716    }
3717
3718    /// A made-up claim with a price on it.
3719    fn sized(mut made: crate::walk::Hit, bytes: u64) -> crate::walk::Hit {
3720        made.size = Size::Measured(bytes);
3721        made
3722    }
3723
3724    /// Every claim the current view shows, by path.
3725    fn shown_claims(view: &View) -> Vec<PathBuf> {
3726        let mut found: Vec<PathBuf> = (0..view.tree().minted())
3727            .filter(|&id| view.tree().is_attached(id))
3728            .filter(|&id| view.tree().node(id).hit.is_some() && view.roll(id).claims > 0)
3729            .map(|id| view.tree().node(id).path.clone())
3730            .collect();
3731        found.sort();
3732        found
3733    }
3734
3735    /// Presses `f` until the view is the one named.
3736    ///
3737    /// One press more than there are presets, because from a view the axis keys built the first
3738    /// press lands on `default` rather than stepping from a place the reader never was.
3739    fn showing(view: &mut View, preset: Preset) {
3740        for _ in 0..=Preset::ALL.len() {
3741            if view.preset() == Some(preset) {
3742                return;
3743            }
3744            view.apply(Action::CyclePreset(Turn::Next));
3745        }
3746        panic!("{preset} is not on the cycle");
3747    }
3748
3749    #[test]
3750    fn a_run_opens_on_default_and_the_header_says_what_default_leaves_out() {
3751        // `default` narrows: it shows what rules named and hides the gitignore fallback. That
3752        // is a filter that is on without having been asked for, which is the shape the age
3753        // floor was resolved against — so what makes it honest rather than *silent* is that
3754        // the count it hides is on the header from the first frame, beside the number it
3755        // qualifies. A narrowed headline that does not say it is narrowed is the failure.
3756        let view = mixed();
3757        assert_eq!(view.preset(), Some(Preset::Default));
3758        assert_eq!(view.total().claims, 4);
3759        assert_eq!(view.out_of_view(), 1);
3760        assert_eq!(view.view_label(), "default");
3761    }
3762
3763    #[test]
3764    fn one_key_walks_the_four_views_that_were_asked_for_in_that_order() {
3765        let mut view = mixed();
3766        let seen = |view: &View| view.total().claims;
3767
3768        // default: everything a rule put a name to, and not the gitignored one.
3769        assert_eq!(view.preset(), Some(Preset::Default));
3770        assert_eq!(seen(&view), 4);
3771
3772        view.apply(Action::CyclePreset(Turn::Next));
3773        // dependencies: one axis narrowed, the other left exactly as default had it.
3774        assert_eq!(view.preset(), Some(Preset::Dependencies));
3775        assert_eq!(seen(&view), 1);
3776
3777        view.apply(Action::CyclePreset(Turn::Next));
3778        // all-ignored: the TIER axis widens and the kind narrowing is RETAINED, so this is the
3779        // step before it plus the gitignored tier — two claims, not five.
3780        assert_eq!(view.preset(), Some(Preset::AllIgnored));
3781        assert_eq!(seen(&view), 2);
3782
3783        view.apply(Action::CyclePreset(Turn::Next));
3784        // all: the kind axis widens too, which is everything.
3785        assert_eq!(view.preset(), Some(Preset::All));
3786        assert_eq!(seen(&view), 5);
3787        assert_eq!(view.out_of_view(), 0);
3788
3789        view.apply(Action::CyclePreset(Turn::Next));
3790        assert_eq!(view.preset(), Some(Preset::Default));
3791
3792        // …and backwards, because a reader who overshoots by one keystroke should not have to
3793        // go all the way round.
3794        view.apply(Action::CyclePreset(Turn::Prev));
3795        assert_eq!(view.preset(), Some(Preset::All));
3796    }
3797
3798    #[test]
3799    fn each_step_of_the_cycle_moves_one_axis_and_carries_the_other() {
3800        // What makes the asked-for order a path rather than four unrelated points, seen through
3801        // the keys: `dependencies` narrows the kind, `all-ignored` widens the tier and KEEPS
3802        // that narrowing, and `all` widens the kind back. Four presses, four different screens.
3803        let mut view = mixed();
3804        let seen = |view: &View| shown_claims(view);
3805
3806        assert_eq!(view.view_label(), "default");
3807        view.apply(Action::CyclePreset(Turn::Next));
3808        assert_eq!(seen(&view), [PathBuf::from("/scan/nx/node_modules")]);
3809
3810        view.apply(Action::CyclePreset(Turn::Next));
3811        assert_eq!(
3812            seen(&view),
3813            [
3814                PathBuf::from("/scan/nx/node_modules"),
3815                PathBuf::from("/scan/nx/out"),
3816            ],
3817            "all-ignored dropped the kind narrowing instead of carrying it"
3818        );
3819
3820        view.apply(Action::CyclePreset(Turn::Next));
3821        assert_eq!(view.total().claims, 5);
3822    }
3823
3824    #[test]
3825    fn no_preset_touches_the_pattern() {
3826        // The pattern narrows whatever the axes leave, so it is orthogonal to both — and a
3827        // preset that quietly dropped it would be using an unrelated piece of state to mean
3828        // something about the view. An earlier pass did exactly that to tell two presets apart.
3829        let mut view = mixed();
3830        filter(&mut view, "nx");
3831        for _ in 0..=Preset::ALL.len() {
3832            view.apply(Action::CyclePreset(Turn::Next));
3833            assert_eq!(view.filter(), Some("nx"), "{:?}", view.preset());
3834        }
3835    }
3836
3837    #[test]
3838    fn the_two_axes_compose_rather_than_replacing_each_other() {
3839        // The whole reason the filter is two axes rather than four modes: a pattern narrows
3840        // whatever the axes left, and neither has to know the other exists.
3841        let mut view = mixed();
3842        showing(&mut view, Preset::Default);
3843        filter(&mut view, "nx");
3844        assert_eq!(
3845            view.total().claims,
3846            3,
3847            "the gitignored one is out either way"
3848        );
3849        assert_eq!(view.lens().describe(), "named · every kind · /nx");
3850    }
3851
3852    #[test]
3853    fn each_axis_has_a_key_of_its_own_so_a_non_preset_view_is_reachable() {
3854        // **Expressible has to mean expressible by a reader.** "Every cache a rule named" is
3855        // not one of the four presets and never will be, and a model that can hold it while no
3856        // keystroke can ask for it is a model with a claim it cannot cash. `d` and `b` take the
3857        // other two kinds off `default`, and what is left is exactly that view.
3858        let mut view = mixed();
3859        for kind in Kind::ALL.into_iter().filter(|&kind| kind != Kind::Cache) {
3860            view.apply(Action::ToggleKind(kind));
3861        }
3862
3863        assert_eq!(view.preset(), None, "a hand-built view is not a preset");
3864        assert_eq!(view.view_label(), "named · cache");
3865        assert_eq!(shown_claims(&view), [PathBuf::from("/scan/nx/.nx/cache")]);
3866
3867        // …and the tier axis moves on its own, leaving the kind axis exactly where it was.
3868        view.apply(Action::CycleTiers);
3869        assert_eq!(view.view_label(), "named + gitignored · cache");
3870        assert_eq!(
3871            shown_claims(&view),
3872            [
3873                PathBuf::from("/scan/nx/.nx/cache"),
3874                PathBuf::from("/scan/nx/out"),
3875            ]
3876        );
3877
3878        // Once more and the named tier goes, which is the third state of that axis and the one
3879        // no preset names either.
3880        view.apply(Action::CycleTiers);
3881        assert_eq!(view.view_label(), "gitignored · cache");
3882        assert_eq!(shown_claims(&view), [PathBuf::from("/scan/nx/out")]);
3883    }
3884
3885    #[test]
3886    fn moving_either_axis_by_hand_leaves_the_selection_exactly_where_it_was() {
3887        // The orthogonality rule does not get to be true only for the presets. An axis key is a
3888        // change to what is *visible*, so it must be as inert on the marks as `f` is.
3889        let mut view = mixed();
3890        showing(&mut view, Preset::All);
3891        point_at(&mut view, "/scan");
3892        view.apply(Action::Mark);
3893        let whole = batched(&view);
3894        assert_eq!(whole.len(), 5);
3895
3896        for action in [
3897            Action::ToggleKind(Kind::Dependencies),
3898            Action::CycleTiers,
3899            Action::ToggleKind(Kind::Cache),
3900            Action::CycleTiers,
3901            Action::ToggleKind(Kind::Build),
3902        ] {
3903            view.apply(action);
3904            assert_eq!(batched(&view), whole, "{action:?} changed the batch");
3905            assert_eq!(view.marked().claims, 5, "{action:?} changed the counter");
3906        }
3907        // …and by this point the view shows nothing at all, which is a legitimate place for
3908        // the axes to be and changes nothing whatever about what is going to be deleted. That
3909        // is the strongest form of the rule: a screen with no rows on it and a batch of five.
3910        assert_eq!(view.total().claims, 0);
3911        assert_eq!(view.hidden(), 5);
3912        assert_eq!(batched(&view).len(), 5);
3913    }
3914
3915    #[test]
3916    fn a_hand_built_view_that_lands_on_a_preset_is_called_by_its_name() {
3917        // The other half of naming the view honestly: a reader who toggles their way onto
3918        // `dependencies` is on `dependencies`, and the footer should say so rather than
3919        // spelling out axes that have a name.
3920        let mut view = mixed();
3921        for kind in Kind::ALL
3922            .into_iter()
3923            .filter(|&kind| kind != Kind::Dependencies)
3924        {
3925            view.apply(Action::ToggleKind(kind));
3926        }
3927        assert_eq!(view.preset(), Some(Preset::Dependencies));
3928        assert_eq!(view.view_label(), "dependencies");
3929
3930        // …and one that lands nowhere near a preset says the axes instead, because that is the
3931        // only description a nameless view has.
3932        view.apply(Action::ToggleKind(Kind::Dependencies));
3933        view.apply(Action::ToggleKind(Kind::Build));
3934        view.apply(Action::CycleTiers);
3935        assert_eq!(view.preset(), None);
3936        assert_eq!(view.view_label(), "named + gitignored · build");
3937    }
3938
3939    #[test]
3940    fn toggling_what_is_visible_never_changes_what_is_selected() {
3941        // **The rule the whole model turns on.** Hiding a row is not unselecting it.
3942        let mut view = mixed();
3943        // Marked through the widest view, so what follows is the whole scan being narrowed
3944        // around a selection rather than a selection that was never that big.
3945        showing(&mut view, Preset::All);
3946        point_at(&mut view, "/scan");
3947        view.apply(Action::Mark);
3948        let whole = batched(&view);
3949        assert_eq!(whole.len(), 5);
3950
3951        for preset in Preset::ALL {
3952            showing(&mut view, preset);
3953            assert_eq!(batched(&view), whole, "{preset} changed the batch");
3954            assert_eq!(view.marked().claims, 5, "{preset} changed the counter");
3955        }
3956    }
3957
3958    #[test]
3959    fn a_mark_keeps_meaning_the_view_it_was_made_through() {
3960        // Mark `~/repos` under Dependencies, widen to All, and the build artefacts under it
3961        // are still unmarked. A mark stored as "the subtree under N" could not do this: it
3962        // would re-derive under the new view and quietly take everything.
3963        let mut view = mixed();
3964        showing(&mut view, Preset::Dependencies);
3965        point_at(&mut view, "/scan/nx");
3966        view.apply(Action::Mark);
3967        assert_eq!(batched(&view), [PathBuf::from("/scan/nx/node_modules")]);
3968
3969        showing(&mut view, Preset::All);
3970        assert_eq!(
3971            batched(&view),
3972            [PathBuf::from("/scan/nx/node_modules")],
3973            "widening the view widened the selection"
3974        );
3975        assert_eq!(view.mark_of(at(&view, "/scan/nx")), Mark::Partial);
3976    }
3977
3978    #[test]
3979    fn a_second_mark_through_a_second_view_adds_to_the_first() {
3980        // The axes are a way of saying what to select, not a mode the selection lives in, so
3981        // two passes over one directory under two views is a union rather than a replacement.
3982        // The second view here is one the axis keys built and no preset names, which is the
3983        // point: a mark carries whatever the reader could see, preset or not.
3984        let mut view = mixed();
3985        showing(&mut view, Preset::Dependencies);
3986        point_at(&mut view, "/scan/nx");
3987        view.apply(Action::Mark);
3988
3989        view.apply(Action::CycleTiers);
3990        view.apply(Action::CycleTiers);
3991        view.apply(Action::ToggleKind(Kind::Dependencies));
3992        assert_eq!(view.view_label(), "gitignored · none");
3993        point_at(&mut view, "/scan/nx");
3994        view.apply(Action::Mark);
3995
3996        showing(&mut view, Preset::All);
3997        assert_eq!(
3998            batched(&view),
3999            [
4000                PathBuf::from("/scan/nx/node_modules"),
4001                PathBuf::from("/scan/nx/out"),
4002            ]
4003        );
4004    }
4005
4006    #[test]
4007    fn the_partial_glyph_is_computed_against_the_view_the_reader_is_looking_through() {
4008        // An ancestor can be FULLY marked under one view and PARTIALLY marked under another,
4009        // and the glyph has to say which — a box drawn full over rows that are visibly
4010        // unmarked is the screen contradicting itself.
4011        let mut view = mixed();
4012        showing(&mut view, Preset::Dependencies);
4013        point_at(&mut view, "/scan/nx");
4014        view.apply(Action::Mark);
4015        assert_eq!(view.mark_of(at(&view, "/scan/nx")), Mark::All);
4016        assert!((view.share(at(&view, "/scan/nx")) - 1.0).abs() < f64::EPSILON);
4017
4018        showing(&mut view, Preset::All);
4019        assert_eq!(view.mark_of(at(&view, "/scan/nx")), Mark::Partial);
4020        // 200 of the 311 bytes under `nx` are marked, and the glyph says so rather than
4021        // merely saying "some".
4022        assert!((view.share(at(&view, "/scan/nx")) - 200.0 / 311.0).abs() < 0.001);
4023
4024        // The case a globally-computed glyph gets exactly backwards: a selection that is
4025        // entirely out of sight, over a row whose visible claims are all unmarked. Counting
4026        // the whole selection would draw the box FULL over rows the reader can see are empty.
4027        //
4028        // Changing the view says so in the footer, and that sentence is a rung of its own —
4029        // so it is taken by name rather than by spending one of the two `Esc`s below on it.
4030        // Those two are the rungs this setup is actually after: the view, then the marks.
4031        view.apply(Action::Dismiss);
4032        view.apply(Action::Back);
4033        view.apply(Action::Back);
4034        assert!(batched(&view).is_empty());
4035        point_at(&mut view, "/scan/nx/dist");
4036        view.apply(Action::Mark);
4037        showing(&mut view, Preset::Dependencies);
4038        assert_eq!(view.marked().claims, 1, "the selection is still there");
4039        assert_eq!(view.mark_of(at(&view, "/scan/nx")), Mark::None);
4040    }
4041
4042    #[test]
4043    fn a_claim_that_streams_in_under_a_mark_joins_it_when_it_matches_that_marks_view() {
4044        // Why the pair is resolved on demand rather than frozen into a list of ids: results
4045        // stream in, so a subtree marked at seven seconds has to pick up what arrives at
4046        // forty. And only what the mark's own view would have taken.
4047        let mut view = mixed();
4048        showing(&mut view, Preset::Dependencies);
4049        point_at(&mut view, "/scan/nx");
4050        view.apply(Action::Mark);
4051
4052        view.found(sized(
4053            of_kind("/scan/nx/packages/ui/node_modules", Kind::Dependencies),
4054            50,
4055        ));
4056        view.found(sized(of_kind("/scan/nx/packages/ui/dist", Kind::Build), 50));
4057        view.sync();
4058
4059        showing(&mut view, Preset::All);
4060        assert_eq!(
4061            batched(&view),
4062            [
4063                PathBuf::from("/scan/nx/node_modules"),
4064                PathBuf::from("/scan/nx/packages/ui/node_modules"),
4065            ],
4066            "a build artefact joined a dependencies mark"
4067        );
4068    }
4069
4070    #[test]
4071    fn sparing_one_row_out_of_a_marked_subtree_keeps_sparing_it_as_more_arrives() {
4072        // The exclusion's advantage over the push-down it replaced. A push-down marks every
4073        // sibling *that exists at that instant*, so a claim arriving beside the spared row a
4074        // minute later would silently be spared too — a statement about a moment, standing in
4075        // for a statement about a directory.
4076        let mut view = mixed();
4077        point_at(&mut view, "/scan");
4078        view.apply(Action::Mark);
4079        point_at(&mut view, "/scan/nx/dist");
4080        view.apply(Action::Mark);
4081        assert!(!batched(&view).contains(&PathBuf::from("/scan/nx/dist")));
4082
4083        view.found(sized(of_kind("/scan/nx/late", Kind::Build), 5));
4084        view.sync();
4085        assert!(
4086            batched(&view).contains(&PathBuf::from("/scan/nx/late")),
4087            "a claim that arrived beside the spared row was spared too"
4088        );
4089        assert!(!batched(&view).contains(&PathBuf::from("/scan/nx/dist")));
4090    }
4091
4092    #[test]
4093    fn a_spared_subtree_can_be_marked_again_from_inside_it() {
4094        // Marks and exclusions interleave down a path, and the deepest thing on it is what
4095        // speaks. A shallower mark must not be able to reach back through an exclusion below
4096        // it, and an exclusion must not be able to hold out against a mark below itself.
4097        let mut view = mixed();
4098        point_at(&mut view, "/scan");
4099        view.apply(Action::Mark);
4100        point_at(&mut view, "/scan/nx");
4101        view.apply(Action::Mark);
4102        assert_eq!(batched(&view), [PathBuf::from("/scan/old/target")]);
4103
4104        point_at(&mut view, "/scan/nx/dist");
4105        view.apply(Action::Mark);
4106        assert_eq!(
4107            batched(&view),
4108            [
4109                PathBuf::from("/scan/nx/dist"),
4110                PathBuf::from("/scan/old/target"),
4111            ]
4112        );
4113    }
4114
4115    #[test]
4116    fn unmarking_a_row_nothing_was_covering_leaves_no_exclusion_behind() {
4117        // An exclusion is invisible: two views with the same rows drawn, the same glyphs and
4118        // the same batch can differ by one, and it only shows up in what a *later* keystroke
4119        // does. Today every later keystroke that could be affected happens to clear it —
4120        // marking a directory drops the exclusions beneath it — so this is a test about the
4121        // state rather than about an observable difference, and that is the point. The
4122        // question `unmark` asks is answered by the counts, and the counts are one keystroke
4123        // behind until the pass is re-run.
4124        let mut view = mixed();
4125        point_at(&mut view, "/scan/nx");
4126        view.apply(Action::Mark);
4127        view.apply(Action::Mark);
4128
4129        assert!(batched(&view).is_empty());
4130        assert!(view.marks.is_empty());
4131        assert!(
4132            view.spared.is_empty(),
4133            "a plain unmark left an exclusion for an ancestor that does not exist"
4134        );
4135    }
4136
4137    #[test]
4138    fn the_batch_is_the_whole_selection_and_the_counter_says_how_much_is_out_of_sight() {
4139        // Deleting acts on everything that is marked, so the number a reader checks has to
4140        // describe the same set. What narrowing the view changes is the count beside it.
4141        let mut view = mixed();
4142        showing(&mut view, Preset::All);
4143        point_at(&mut view, "/scan");
4144        view.apply(Action::Mark);
4145        assert_eq!(view.marked().claims, 5);
4146        assert_eq!(view.hidden(), 0);
4147
4148        showing(&mut view, Preset::Dependencies);
4149        assert_eq!(view.marked().claims, 5, "the counter followed the view");
4150        assert_eq!(batched(&view).len(), 5, "the batch followed the view");
4151        assert_eq!(view.hidden(), 4);
4152        assert!(
4153            view.notice().unwrap().contains("out of sight"),
4154            "{:?}",
4155            view.notice()
4156        );
4157    }
4158
4159    #[test]
4160    fn the_confirmation_lists_the_whole_batch_and_names_what_is_out_of_sight() {
4161        let mut view = mixed();
4162        showing(&mut view, Preset::All);
4163        point_at(&mut view, "/scan");
4164        view.apply(Action::Mark);
4165        showing(&mut view, Preset::Dependencies);
4166        let Effect::Plan(batch) = view.apply(Action::Commit) else {
4167            panic!("the key that writes did not ask");
4168        };
4169        view.asking(
4170            &batch
4171                .iter()
4172                .map(|target| Planned::at(target.path.clone(), target.size))
4173                .collect::<Vec<_>>(),
4174            &[],
4175        );
4176
4177        let pending = view.pending().unwrap();
4178        assert_eq!(pending.entries().len(), 5);
4179        assert_eq!(pending.hidden(), 4);
4180        // Grouped by kind: dependencies, then build, then cache, then the tier nothing named.
4181        let kinds: Vec<Option<Kind>> = pending.entries().iter().map(|entry| entry.kind).collect();
4182        assert_eq!(
4183            kinds,
4184            [
4185                Some(Kind::Dependencies),
4186                Some(Kind::Build),
4187                Some(Kind::Build),
4188                Some(Kind::Cache),
4189                None,
4190            ]
4191        );
4192        // …and each line says whether the reader can currently see it.
4193        let seen: Vec<bool> = pending.entries().iter().map(|entry| entry.hidden).collect();
4194        assert_eq!(seen, [false, true, true, true, true]);
4195    }
4196
4197    // ---- the report and the confirmation are two things, not one ----------------------
4198    //
4199    // Both are drawn over the tree and both say something about a batch, which is the whole
4200    // reason to pin them apart. A [`Notice`] is a report of what has already happened and its
4201    // lifetime is a reader's action; the confirmation is the question asked *before* anything
4202    // happens, and it is mandatory whenever part of the selection is out of sight. Nothing
4203    // that gets rid of the first may touch the second.
4204
4205    #[test]
4206    fn a_standing_report_does_not_stand_in_for_the_confirmation() {
4207        let mut view = mixed();
4208        showing(&mut view, Preset::All);
4209        point_at(&mut view, "/scan");
4210        view.apply(Action::Mark);
4211        showing(&mut view, Preset::Dependencies);
4212        assert_eq!(view.hidden(), 4);
4213
4214        // The stickiest thing the footer can be holding: a report naming what the safety model
4215        // left alone, which waits to be dismissed rather than going with the next keystroke.
4216        view.deleted(
4217            Notice::standing("removed 10 B from 1 directory, 1 directory left alone"),
4218            10,
4219        );
4220
4221        // It does not answer the question, so it must not be allowed to look like an answer.
4222        // `x` still plans, and the box still opens on the whole selection — four fifths of
4223        // which the reader cannot currently see.
4224        let Effect::Plan(batch) = view.apply(Action::Commit) else {
4225            panic!("a report in the footer swallowed the batch");
4226        };
4227        assert!(view.notice_stands(), "the report went with the keystroke");
4228        view.asking(
4229            &batch
4230                .iter()
4231                .map(|target| Planned::at(target.path.clone(), target.size))
4232                .collect::<Vec<_>>(),
4233            &[],
4234        );
4235
4236        let pending = view
4237            .pending()
4238            .expect("no confirmation over a standing report");
4239        assert_eq!(pending.entries().len(), 5);
4240        assert_eq!(pending.hidden(), 4);
4241        // Both on the screen at once, saying different things about different moments.
4242        assert!(view.notice().is_some());
4243    }
4244
4245    #[test]
4246    fn getting_rid_of_the_report_never_gets_rid_of_the_confirmation() {
4247        let mut view = mixed();
4248        showing(&mut view, Preset::All);
4249        point_at(&mut view, "/scan");
4250        view.apply(Action::Mark);
4251        showing(&mut view, Preset::Dependencies);
4252        view.deleted(Notice::standing("1 directory left alone"), 10);
4253        view.asking(&planned(&["/scan/nx/node_modules", "/scan/nx/dist"]), &[]);
4254        assert!(view.pending().is_some());
4255
4256        // A press on the footer is aimed at the sentence and nothing else. The confirmation is
4257        // the one screen where a batch the reader cannot fully see can still be changed, so a
4258        // gesture that means "I have read that" must never be what takes it away.
4259        view.apply(Action::Dismiss);
4260        assert_eq!(view.notice(), None);
4261        assert!(
4262            view.pending().is_some(),
4263            "dismissing the report took the question with it"
4264        );
4265
4266        // And on the ladder the box is in front: one `Esc` closes the question, and a report
4267        // that was underneath it is still underneath it afterwards.
4268        view.deleted(Notice::standing("1 directory left alone"), 10);
4269        view.asking(&planned(&["/scan/nx/node_modules"]), &[]);
4270        view.apply(Action::Back);
4271        assert!(view.pending().is_none(), "Esc did not take the box first");
4272        assert!(view.notice().is_some(), "Esc took both rungs at once");
4273    }
4274
4275    #[test]
4276    fn an_entry_can_be_taken_out_of_the_batch_from_the_confirmation_itself() {
4277        // The answer to a surprise has to be better than "cancel and start again" — especially
4278        // when the surprising row is one the current view is not even showing.
4279        let mut view = mixed();
4280        showing(&mut view, Preset::All);
4281        point_at(&mut view, "/scan");
4282        view.apply(Action::Mark);
4283        showing(&mut view, Preset::Dependencies);
4284        view.asking(&planned(&["/scan/nx/node_modules", "/scan/nx/dist"]), &[]);
4285
4286        // The cursor starts on the first line, and `↓` reaches the hidden one.
4287        view.apply(Action::Listing(Motion::Down));
4288        assert_eq!(
4289            view.pending().unwrap().entries()[view.pending().unwrap().at()].path,
4290            PathBuf::from("/scan/nx/dist")
4291        );
4292        view.apply(Action::Spare);
4293
4294        let pending = view.pending().unwrap();
4295        assert_eq!(pending.entries().len(), 1);
4296        assert_eq!(pending.targets, [PathBuf::from("/scan/nx/node_modules")]);
4297        // The deed shrank with the line, and so did the tree behind the box: a listing that
4298        // stopped showing a directory while the marks kept it would be the disagreement the
4299        // screen exists to prevent.
4300        assert!(!batched(&view).contains(&PathBuf::from("/scan/nx/dist")));
4301        assert_eq!(view.hidden(), 3);
4302    }
4303
4304    #[test]
4305    fn taking_the_last_entry_out_closes_the_question_rather_than_asking_an_empty_one() {
4306        let mut view = mixed();
4307        point_at(&mut view, "/scan/nx/dist");
4308        view.apply(Action::Mark);
4309        view.asking(&planned(&["/scan/nx/dist"]), &[]);
4310        view.apply(Action::Spare);
4311
4312        assert_eq!(view.overlay(), None);
4313        assert!(view.notice().unwrap().contains("nothing left"));
4314        assert!(batched(&view).is_empty());
4315    }
4316
4317    #[test]
4318    fn a_refused_directory_says_so_on_the_confirmation_rather_than_in_the_report() {
4319        // The same refusal reporting, moved to the one moment where it can still change a
4320        // decision. A reader who marked forty and is going to get thirty-eight should not
4321        // learn that afterwards.
4322        let mut view = mixed();
4323        view.asking(
4324            &planned(&["/scan/nx/node_modules"]),
4325            &[Refused {
4326                path: "/scan/old/target".into(),
4327                reason: Refusal::HoldsCheckout,
4328            }],
4329        );
4330        let pending = view.pending().unwrap();
4331        assert_eq!(pending.kept(), 1);
4332        let refused = pending
4333            .entries()
4334            .iter()
4335            .find(|entry| entry.kept.is_some())
4336            .unwrap();
4337        assert_eq!(refused.path, PathBuf::from("/scan/old/target"));
4338        assert!(refused.kept.as_ref().unwrap().contains("git checkout"));
4339        // It is not on the deed, which is the point of saying it: the box promises exactly
4340        // what it lists as going.
4341        assert_eq!(pending.targets, [PathBuf::from("/scan/nx/node_modules")]);
4342    }
4343
4344    #[test]
4345    fn escape_takes_the_pattern_off_before_the_view_and_the_marks_last_of_all() {
4346        // Two independent narrowings, so they come off as two rungs. A reader who typed a
4347        // pattern over `dependencies` means to lose the pattern.
4348        let mut view = mixed();
4349        showing(&mut view, Preset::Dependencies);
4350        filter(&mut view, "nx");
4351        point_at(&mut view, "/scan");
4352        view.apply(Action::Mark);
4353
4354        view.apply(Action::Back);
4355        assert_eq!(view.filter(), None);
4356        assert_eq!(view.preset(), Some(Preset::Dependencies));
4357
4358        view.apply(Action::Back);
4359        assert_eq!(view.preset(), Some(Preset::Default));
4360        assert!(!batched(&view).is_empty(), "the marks went with the view");
4361
4362        view.apply(Action::Back);
4363        assert!(batched(&view).is_empty());
4364    }
4365
4366    // ---- the filter -------------------------------------------------------------------
4367
4368    #[test]
4369    fn a_filter_keeps_the_ancestors_of_what_it_matches() {
4370        let mut view = view();
4371        filter(&mut view, "ui/node_modules");
4372        assert_eq!(view.filter(), Some("ui/node_modules"));
4373        assert_eq!(shown(&view), ["/scan", "  nx"]);
4374    }
4375
4376    #[test]
4377    fn a_filtered_row_is_worth_what_the_filter_shows_rather_than_what_is_under_it() {
4378        let mut view = view();
4379        filter(&mut view, "ui/node_modules");
4380
4381        // `nx` is worth 300 in the scan and 100 of that matches. Showing 300 over a filtered
4382        // tree would be a row whose mark deletes twice what it says.
4383        assert_eq!(view.roll(at(&view, "/scan/nx")).bytes, 100);
4384        assert_eq!(view.roll(at(&view, "/scan/nx")).claims, 1);
4385        assert_eq!(view.total().bytes, 100);
4386    }
4387
4388    #[test]
4389    fn marking_a_filtered_row_never_deletes_what_the_filter_is_hiding() {
4390        let mut view = view();
4391        filter(&mut view, "ui/node_modules");
4392        point_at(&mut view, "/scan/nx");
4393        view.apply(Action::Mark);
4394
4395        // The safety rule the filtered rollup exists for: `nx/node_modules` is under a marked
4396        // row, and it is not on screen, so it is not in the batch.
4397        assert_eq!(
4398            batched(&view),
4399            [PathBuf::from("/scan/nx/packages/ui/node_modules")]
4400        );
4401        assert_eq!(view.marked().bytes, 100);
4402    }
4403
4404    #[test]
4405    fn a_pattern_the_engine_refuses_leaves_the_prompt_up_and_says_why() {
4406        let mut view = view();
4407        view.apply(Action::OpenFilter);
4408        for character in "node_(".chars() {
4409            view.apply(Action::Type(character));
4410        }
4411        view.apply(Action::Submit);
4412
4413        assert_eq!(view.overlay(), Some(Overlay::Prompt));
4414        assert!(view.prompt().unwrap().error().is_some());
4415        // Closing the prompt on a bad pattern and showing an unfiltered tree would look
4416        // exactly like a filter that matched everything.
4417        assert_eq!(view.filter(), None);
4418    }
4419
4420    #[test]
4421    fn the_prompt_edits_like_a_text_field() {
4422        let mut view = view();
4423        view.apply(Action::OpenFilter);
4424        for character in "node".chars() {
4425            view.apply(Action::Type(character));
4426        }
4427        view.apply(Action::Caret(Motion::Top));
4428        view.apply(Action::Type('x'));
4429        assert_eq!(view.prompt().unwrap().text(), "xnode");
4430        assert_eq!(view.prompt().unwrap().caret(), 1);
4431        view.apply(Action::Erase);
4432        assert_eq!(view.prompt().unwrap().text(), "node");
4433        view.apply(Action::EraseAhead);
4434        assert_eq!(view.prompt().unwrap().text(), "ode");
4435        view.apply(Action::Wipe);
4436        assert_eq!(view.prompt().unwrap().text(), "");
4437        // An empty pattern submitted is how a filter is taken off.
4438        view.apply(Action::Submit);
4439        assert_eq!(view.filter(), None);
4440        assert_eq!(view.overlay(), None);
4441    }
4442
4443    // ---- committing a batch -----------------------------------------------------------
4444
4445    #[test]
4446    fn the_delete_key_asks_before_anything_leaves_the_view() {
4447        let mut view = view();
4448        point_at(&mut view, "/scan/nx");
4449        view.apply(Action::Mark);
4450
4451        let effect = view.apply(Action::Commit);
4452
4453        // It hands the batch out to be *planned*. Nothing is removed by pressing it, and the
4454        // view has no way to remove anything itself.
4455        let Effect::Plan(targets) = effect else {
4456            panic!("the delete key did something other than ask for a plan");
4457        };
4458        assert_eq!(
4459            targets.iter().map(|t| t.path.clone()).collect::<Vec<_>>(),
4460            [
4461                PathBuf::from("/scan/nx/node_modules"),
4462                PathBuf::from("/scan/nx/packages/ui/node_modules"),
4463            ]
4464        );
4465        // …carrying what the scan priced, because a plan built from bare paths would offer
4466        // to delete 300 bytes of `node_modules` "giving back 0 B" — which is what the box
4467        // said before this was a `Target`.
4468        assert_eq!(targets[0].size, Size::Measured(200));
4469        assert_eq!(view.overlay(), None);
4470    }
4471
4472    #[test]
4473    fn the_confirmation_opens_on_cancel_and_enter_takes_the_highlighted_answer() {
4474        let mut view = view();
4475        view.asking(&planned(&["/scan/old/target"]), &[]);
4476        assert_eq!(view.overlay(), Some(Overlay::Confirm));
4477        assert_eq!(view.pending().unwrap().answer, Answer::Cancel);
4478
4479        assert_eq!(view.apply(Action::Answer), Effect::None);
4480        assert_eq!(view.overlay(), None);
4481        assert!(!view.is_deleting());
4482
4483        view.asking(&planned(&["/scan/old/target"]), &[]);
4484        view.apply(Action::Highlight(Turn::Next));
4485        assert_eq!(
4486            view.apply(Action::Answer),
4487            Effect::Delete(vec![PathBuf::from("/scan/old/target")])
4488        );
4489        assert!(view.is_deleting());
4490    }
4491
4492    #[test]
4493    fn the_deed_is_what_the_question_named_rather_than_what_is_marked_when_it_is_answered() {
4494        let mut view = view();
4495        view.asking(&planned(&["/scan/old/target"]), &[]);
4496
4497        // The tree moves while a box is up: a claim arrives, and the reader marks it. The
4498        // answer must still mean the directory the box described.
4499        view.found(hit("/scan/late/node_modules", Size::Measured(1), 1));
4500        view.sync();
4501        point_at(&mut view, "/scan/late");
4502        view.apply(Action::Mark);
4503
4504        view.apply(Action::Highlight(Turn::Next));
4505        assert_eq!(
4506            view.apply(Action::Answer),
4507            Effect::Delete(vec![PathBuf::from("/scan/old/target")])
4508        );
4509    }
4510
4511    #[test]
4512    fn committing_nothing_says_so_instead_of_asking_an_empty_question() {
4513        let mut view = view();
4514        assert_eq!(view.apply(Action::Commit), Effect::None);
4515        assert!(view.notice().unwrap().contains("nothing is marked"));
4516
4517        // …and so does a batch the safety model refused in full.
4518        view.asking(
4519            &[],
4520            &[Refused {
4521                path: "/scan/old/target".into(),
4522                reason: Refusal::HoldsCheckout,
4523            }],
4524        );
4525        assert_eq!(view.overlay(), None);
4526        assert!(view.notice().unwrap().contains("left alone"));
4527    }
4528
4529    #[test]
4530    fn a_second_delete_while_one_is_running_is_refused_rather_than_racing_it() {
4531        let mut view = view();
4532        view.asking(&planned(&["/scan/old/target"]), &[]);
4533        view.apply(Action::Highlight(Turn::Next));
4534        view.apply(Action::Answer);
4535
4536        point_at(&mut view, "/scan/nx");
4537        view.apply(Action::Mark);
4538        assert_eq!(view.apply(Action::Commit), Effect::None);
4539        assert!(view.notice().unwrap().contains("already running"));
4540    }
4541
4542    // ---- the overlays -----------------------------------------------------------------
4543
4544    #[test]
4545    fn escape_walks_back_out_one_rung_at_a_time_and_never_quits() {
4546        let mut view = view();
4547        point_at(&mut view, "/scan/nx");
4548        view.apply(Action::Mark);
4549        filter(&mut view, "node_modules");
4550        view.apply(Action::Help);
4551        view.apply(Action::OpenFilter);
4552
4553        assert_eq!(view.apply(Action::Back), Effect::None);
4554        assert_eq!(view.overlay(), Some(Overlay::Help));
4555        view.apply(Action::Back);
4556        assert_eq!(view.overlay(), None);
4557        view.apply(Action::Back);
4558        assert_eq!(view.filter(), None);
4559        view.apply(Action::Back);
4560        assert_eq!(view.marked().claims, 0);
4561        // The bottom rung does nothing at all. `q` is the way out, and it is on the help page.
4562        assert_eq!(view.apply(Action::Back), Effect::None);
4563    }
4564
4565    // ---- the map pane, and the two reasons there is not one ----------------------------
4566
4567    #[test]
4568    fn m_on_a_terminal_that_reports_no_pixel_size_says_that_rather_than_blaming_the_protocol() {
4569        // #656. Both refusals were one boolean, so the only sentence the key had was the
4570        // protocol one — which is the wrong sentence inside tmux, where the terminal outside
4571        // reads the protocol perfectly well and the thing in the way is the multiplexer.
4572        let mut multiplexed = view();
4573        multiplexed.allow_maps(Maps::Unmeasured);
4574        multiplexed.apply(Action::ToggleMap);
4575        let said = multiplexed.notice().unwrap();
4576        assert!(said.contains("pixel size"), "{said}");
4577        assert!(
4578            !multiplexed.maps(),
4579            "a map was turned on that cannot be drawn"
4580        );
4581
4582        // And the other terminal still gets the other sentence.
4583        let mut plain = view();
4584        plain.allow_maps(Maps::Unread);
4585        plain.apply(Action::ToggleMap);
4586        assert_eq!(
4587            plain.notice(),
4588            Some("this terminal does not read the graphics protocol, so there is no map")
4589        );
4590    }
4591
4592    #[test]
4593    fn a_window_that_loses_its_pixel_size_gives_the_columns_back_and_says_why() {
4594        // A tmux client attaching to a session mid-run, or a window moving to a display the
4595        // terminal measures differently. The answer is not a start-up constant, so the pane
4596        // has to be able to go — and going without a word is the empty rectangle again, one
4597        // frame later.
4598        let mut view = view();
4599        view.allow_maps(Maps::Can);
4600        assert!(view.maps());
4601
4602        view.allow_maps(Maps::Unmeasured);
4603        assert!(!view.maps(), "the tree is still paying for the pane");
4604        assert!(view.notice().unwrap().contains("pixel size"));
4605
4606        // …and it comes back on its own when the window can be measured again, without the
4607        // reader having to press anything: `m` is theirs, and this is not.
4608        view.apply(Action::Back);
4609        view.allow_maps(Maps::Can);
4610        assert!(view.maps());
4611        assert_eq!(
4612            view.notice(),
4613            None,
4614            "it announced a map that is simply back"
4615        );
4616    }
4617
4618    #[test]
4619    fn being_told_the_same_answer_again_is_not_news() {
4620        // Told ten times a second, so saying it twice is a footer that says nothing else for
4621        // the rest of the run — and marking the view stale each time is the whole tree's
4622        // stamps re-folded to learn that nothing changed.
4623        let mut view = view();
4624        view.allow_maps(Maps::Unmeasured);
4625        view.apply(Action::Back);
4626        assert_eq!(view.notice(), None);
4627
4628        for _ in 0..10 {
4629            view.allow_maps(Maps::Unmeasured);
4630        }
4631        assert_eq!(view.notice(), None, "it said it again");
4632    }
4633
4634    #[test]
4635    fn a_terminal_that_never_could_draw_one_says_nothing_at_start_up() {
4636        // The edge and not the state: a run that opens inside tmux has lost nothing, and a
4637        // footer that opens by naming a feature the reader never asked about is noise.
4638        let mut view = view();
4639        view.allow_maps(Maps::Unmeasured);
4640        assert_eq!(view.notice(), None);
4641    }
4642
4643    // ---- what the footer says, and how it stops saying it ------------------------------
4644
4645    #[test]
4646    fn a_report_of_what_was_removed_can_be_got_rid_of() {
4647        let mut view = view();
4648        view.deleted(Notice::passing("removed 10 B from 1 directory"), 10);
4649        assert_eq!(view.notice(), Some("removed 10 B from 1 directory"));
4650
4651        // The bug this rung exists for: without it the sentence sits over the keys for the
4652        // rest of the run, and there is no key that takes it away.
4653        view.apply(Action::Back);
4654        assert_eq!(view.notice(), None);
4655    }
4656
4657    #[test]
4658    fn the_next_thing_the_reader_does_takes_an_ordinary_report_away() {
4659        let mut view = view();
4660        view.deleted(Notice::passing("removed 10 B from 1 directory"), 10);
4661
4662        // Moving the cursor is the reader looking at the tree the report describes, by which
4663        // point the report describes the frame before. No timer: every lifetime here is an
4664        // action, so nothing can expire while somebody is reading it.
4665        view.apply(Action::Cursor(Motion::Down));
4666        assert_eq!(view.notice(), None);
4667    }
4668
4669    #[test]
4670    fn a_key_nobody_bound_is_not_the_reader_having_read_the_report() {
4671        let mut view = view();
4672        view.deleted(Notice::passing("removed 10 B from 1 directory"), 10);
4673        view.apply(Action::Ignore);
4674        assert!(view.notice().is_some());
4675    }
4676
4677    #[test]
4678    fn a_report_naming_a_refusal_outlives_the_keys_that_clear_an_ordinary_one() {
4679        let mut view = view();
4680        view.deleted(
4681            Notice::standing("removed 10 B from 1 directory, 1 directory failed"),
4682            10,
4683        );
4684        assert!(view.notice_stands());
4685
4686        // The safety model's counts are what the run exits non-zero on, so this line is where
4687        // a reader learns of them. An arrow key pressed while reading it, or a sort they
4688        // reach for to go and look, must not be what takes it away.
4689        for action in [
4690            Action::Cursor(Motion::Down),
4691            Action::Expand,
4692            Action::CycleSort,
4693            Action::Mark,
4694        ] {
4695            view.apply(action);
4696            assert!(view.notice().is_some(), "{action:?} took it away");
4697        }
4698
4699        // Asked for explicitly, it goes — a reader who has been given the chance to see it.
4700        view.apply(Action::Back);
4701        assert_eq!(view.notice(), None);
4702        assert!(!view.notice_stands());
4703    }
4704
4705    #[test]
4706    fn a_newer_report_answers_the_keystroke_that_asked_for_it_even_over_a_standing_one() {
4707        let mut view = view();
4708        view.deleted(
4709            Notice::standing("removed 10 B from 1 directory, 1 failed"),
4710            10,
4711        );
4712
4713        // Not an incidental keypress: `x` on nothing marked is the reader asking a question,
4714        // and the footer is where it is answered. There is only ever one footer, so the newer
4715        // sentence wins — the alternative is a key that visibly does nothing.
4716        assert_eq!(view.apply(Action::Commit), Effect::None);
4717        assert!(view.notice().unwrap().contains("nothing is marked"));
4718        assert!(!view.notice_stands());
4719    }
4720
4721    #[test]
4722    fn dismissing_a_report_is_one_rung_and_does_not_also_drop_the_filter() {
4723        let mut view = view();
4724        point_at(&mut view, "/scan/nx");
4725        view.apply(Action::Mark);
4726        filter(&mut view, "node_modules");
4727        view.deleted(Notice::passing("removed 10 B from 1 directory"), 10);
4728
4729        // One `Esc`, one rung. The notice goes first because it is the cheapest rung to take
4730        // by mistake; dropping the marks instead would be the expensive one.
4731        view.apply(Action::Back);
4732        assert_eq!(view.notice(), None);
4733        assert!(view.filter().is_some());
4734        assert_ne!(view.marked().claims, 0);
4735
4736        view.apply(Action::Back);
4737        assert_eq!(view.filter(), None);
4738        view.apply(Action::Back);
4739        assert_eq!(view.marked().claims, 0);
4740    }
4741
4742    #[test]
4743    fn a_press_on_a_report_that_has_already_gone_does_not_take_the_filter_with_it() {
4744        let mut view = view();
4745        filter(&mut view, "node_modules");
4746        view.deleted(Notice::passing("removed 10 B from 1 directory"), 10);
4747
4748        // A press is aimed at the frame the reader was looking at and acted on at the release,
4749        // so the report can go in between — a keystroke during a held button is all it takes.
4750        // `Back` would fall through to the rung below, and the rung below is their filter.
4751        view.apply(Action::Cursor(Motion::Down));
4752        assert_eq!(view.notice(), None);
4753
4754        view.apply(Action::Dismiss);
4755        assert!(view.filter().is_some(), "the dismissal fell through");
4756    }
4757
4758    #[test]
4759    fn an_overlay_is_dismissed_before_the_report_behind_it() {
4760        let mut view = view();
4761        view.deleted(
4762            Notice::standing("removed 10 B from 1 directory, 1 failed"),
4763            10,
4764        );
4765        view.apply(Action::Help);
4766
4767        // The overlays are drawn over the footer, so they are what is in front of the reader
4768        // and the first `Esc` is theirs. The report is still underneath afterwards — which is
4769        // the point of a standing one: going to read the key list is not having read it.
4770        view.apply(Action::Back);
4771        assert_eq!(view.overlay(), None);
4772        assert!(view.notice().is_some(), "the help took the report with it");
4773        view.apply(Action::Back);
4774        assert_eq!(view.notice(), None);
4775    }
4776
4777    #[test]
4778    fn the_clock_that_drives_everything_else_on_the_frame_does_not_reach_the_report() {
4779        let mut view = view();
4780        let start = Instant::now();
4781        view.deleted(
4782            Notice::standing("removed 10 B from 1 directory, 1 directory failed"),
4783            10,
4784        );
4785
4786        // A minute of frames, which is what a reader who walked away comes back to. Every
4787        // other moving thing on screen has long since settled — the arrival wash, the dimmed
4788        // rows, the counter climbing — and this is the one that must not, because the tree it
4789        // reports on is gone and the exit status is the only other place these counts appear.
4790        for tick in 1..=600 {
4791            view.animate(start + Duration::from_millis(100) * tick);
4792        }
4793        assert!(view.notice().is_some(), "a clock took the report away");
4794        assert!(view.notice_stands());
4795
4796        // And an ordinary report is no more perishable — what ends one is an action, so a
4797        // frame is not it. The difference between the two lifetimes is *which* actions count.
4798        view.apply(Action::Dismiss);
4799        view.deleted(Notice::passing("removed 10 B from 1 directory"), 20);
4800        for tick in 1..=600 {
4801            view.animate(start + Duration::from_millis(100) * tick);
4802        }
4803        assert!(view.notice().is_some(), "a clock took the report away");
4804    }
4805
4806    #[test]
4807    fn the_viewport_follows_the_cursor_and_never_hangs_off_the_end_of_the_rows() {
4808        let mut tree = Tree::new("/scan");
4809        for n in 0..30 {
4810            tree.insert(hit(
4811                &format!("/scan/p{n:02}/node_modules"),
4812                Size::Measured(1),
4813                0,
4814            ));
4815        }
4816        let mut view = View::new(tree);
4817        view.viewport(10);
4818        assert_eq!(view.scroll(), 0);
4819
4820        view.apply(Action::Cursor(Motion::Bottom));
4821        assert_eq!(view.cursor(), Some(30));
4822        assert_eq!(
4823            view.scroll(),
4824            21,
4825            "the cursor is off the bottom of the pane"
4826        );
4827
4828        view.apply(Action::Cursor(Motion::PageUp));
4829        assert_eq!(view.cursor(), Some(20));
4830        assert_eq!(view.scroll(), 20);
4831
4832        // A filter that hides everything leaves the viewport a long way down with nothing
4833        // under it. Left there, the pane draws as empty over a tree that is full.
4834        filter(&mut view, "matches nothing at all");
4835        assert_eq!(view.scroll(), 0);
4836    }
4837
4838    // ---- what a pointer does ----------------------------------------------------------
4839
4840    #[test]
4841    fn a_click_selects_the_directory_it_landed_on_and_not_the_position_it_was_at() {
4842        let mut view = view();
4843        point_at(&mut view, "/scan/nx");
4844        view.apply(Action::Expand);
4845        let old = at(&view, "/scan/old");
4846        assert_eq!(
4847            shown(&view),
4848            ["/scan", "  nx", "    node_modules", "    packages", "  old"]
4849        );
4850
4851        // A claim arrives and re-sorts the level: `old` is now row 1 where it was row 4. A
4852        // press taken as a *position* would select `nx`, whose subtree is everything the
4853        // reader was looking at.
4854        view.found(hit("/scan/old/big/node_modules", Size::Measured(9_000), 50));
4855        view.sync();
4856        assert_eq!(
4857            shown(&view),
4858            ["/scan", "  old", "  nx", "    node_modules", "    packages"]
4859        );
4860
4861        view.apply(Action::Select(old));
4862        assert_eq!(
4863            view.tree().node(view.row().unwrap().id).path,
4864            PathBuf::from("/scan/old")
4865        );
4866    }
4867
4868    #[test]
4869    fn a_click_on_a_row_that_is_gone_leaves_the_cursor_where_it_is() {
4870        let mut view = view();
4871        point_at(&mut view, "/scan/nx");
4872        let target = at(&view, "/scan/old/target");
4873
4874        // The row the press aimed at has been deleted between the press and the release.
4875        // Doing nothing is the honest outcome; the alternative is acting on whatever is now
4876        // at that position.
4877        view.removed(Path::new("/scan/old/target"), 10, true);
4878        // Past the dimmed beat, which is what actually detaches the row now: a complete
4879        // removal empties it on this frame and takes it out of the tree a moment later.
4880        settle(&mut view);
4881        view.apply(Action::Select(target));
4882
4883        assert_eq!(
4884            view.tree().node(view.row().unwrap().id).path,
4885            PathBuf::from("/scan/nx")
4886        );
4887    }
4888
4889    #[test]
4890    fn a_click_on_the_indicator_opens_the_row_and_a_click_on_a_leafs_does_nothing_but_select() {
4891        let mut view = view();
4892        let nx = at(&view, "/scan/nx");
4893        view.apply(Action::OpenRow(nx));
4894        assert_eq!(
4895            shown(&view),
4896            ["/scan", "  nx", "    node_modules", "    packages", "  old"]
4897        );
4898        view.apply(Action::OpenRow(nx));
4899        assert_eq!(shown(&view), ["/scan", "  nx", "  old"]);
4900
4901        // A leaf leaves the indicator's cell blank, so a press there cannot have been aimed
4902        // at one. It selects the row and stops.
4903        view.apply(Action::OpenRow(nx));
4904        let leaf = at(&view, "/scan/nx/node_modules");
4905        view.apply(Action::OpenRow(leaf));
4906        assert_eq!(
4907            view.tree().node(view.row().unwrap().id).path,
4908            PathBuf::from("/scan/nx/node_modules")
4909        );
4910        assert_eq!(
4911            shown(&view),
4912            ["/scan", "  nx", "    node_modules", "    packages", "  old"]
4913        );
4914    }
4915
4916    #[test]
4917    fn a_click_on_the_box_marks_exactly_what_the_key_marks() {
4918        let mut view = view();
4919        let nx = at(&view, "/scan/nx");
4920        view.apply(Action::MarkRow(nx));
4921
4922        // One door, not two: the box under the pointer and `space` reach the same code, so a
4923        // mark cannot mean one thing pressed and another typed.
4924        assert_eq!(view.mark_of(nx), Mark::All);
4925        assert_eq!(view.marked().claims, 2);
4926        assert_eq!(
4927            view.tree().node(view.row().unwrap().id).path,
4928            PathBuf::from("/scan/nx")
4929        );
4930
4931        view.apply(Action::MarkRow(nx));
4932        assert_eq!(view.marked().claims, 0);
4933    }
4934
4935    #[test]
4936    fn naming_an_order_twice_turns_it_upside_down_and_a_new_column_starts_the_right_way_up() {
4937        let mut view = view();
4938        assert_eq!(view.sort(), Sort::by(Order::Size));
4939
4940        view.apply(Action::SortBy(Order::Path));
4941        assert_eq!(view.sort(), Sort::by(Order::Path));
4942        view.apply(Action::SortBy(Order::Path));
4943        assert_eq!(
4944            view.sort(),
4945            Sort {
4946                by: Order::Path,
4947                reverse: true
4948            }
4949        );
4950
4951        // A new column starts in its own natural order rather than inheriting the reversal.
4952        // Carrying it across would reverse something the reader never asked to reverse.
4953        view.apply(Action::SortBy(Order::Size));
4954        assert_eq!(view.sort(), Sort::by(Order::Size));
4955    }
4956
4957    #[test]
4958    fn the_wheel_moves_the_viewport_and_takes_the_cursor_with_it() {
4959        let mut tree = Tree::new("/scan");
4960        for n in 0..30 {
4961            tree.insert(hit(
4962                &format!("/scan/p{n:02}/node_modules"),
4963                Size::Measured(1),
4964                0,
4965            ));
4966        }
4967        let mut view = View::new(tree);
4968        view.viewport(10);
4969        view.apply(Action::Cursor(Motion::Top));
4970        assert_eq!((view.scroll(), view.cursor()), (0, Some(0)));
4971
4972        view.apply(Action::ScrollRows(Motion::Down));
4973        // Three rows a notch, and the cursor is pushed to the top of what is now drawn: a
4974        // cursor left off the screen is a `space` aimed at a row nobody can see.
4975        assert_eq!((view.scroll(), view.cursor()), (3, Some(3)));
4976
4977        view.apply(Action::ScrollRows(Motion::Up));
4978        assert_eq!(view.scroll(), 0);
4979        // Coming back up leaves the cursor where it was — it is inside the pane again, and
4980        // scrolling is not choosing.
4981        assert_eq!(view.cursor(), Some(3));
4982
4983        // …and the wheel cannot scroll the last row off into an empty pane.
4984        for _ in 0..40 {
4985            view.apply(Action::ScrollRows(Motion::Down));
4986        }
4987        assert_eq!(view.scroll(), view.rows().len() - 10);
4988    }
4989
4990    #[test]
4991    fn a_wheel_over_a_view_with_no_cursor_does_not_hand_it_one() {
4992        let mut view = view();
4993        filter(&mut view, "nothing matches this");
4994        view.apply(Action::Back);
4995        assert_eq!(view.cursor(), None);
4996
4997        view.apply(Action::ScrollRows(Motion::Down));
4998        assert_eq!(view.cursor(), None, "scrolling chose a row");
4999    }
5000
5001    #[test]
5002    fn a_double_click_asks_for_a_price_on_what_is_under_the_row_and_nothing_else() {
5003        let mut tree = Tree::new("/scan");
5004        tree.insert(hit("/scan/nx/node_modules", Size::Unmeasured, 900));
5005        tree.insert(hit(
5006            "/scan/nx/packages/ui/node_modules",
5007            Size::Measured(5),
5008            800,
5009        ));
5010        tree.insert(hit("/scan/old/target", Size::Unmeasured, 100));
5011        let mut view = View::new(tree);
5012        view.viewport(40);
5013
5014        let nx = at(&view, "/scan/nx");
5015        let effect = view.apply(Action::Price(nx));
5016
5017        // Only what carries no price, and only what is under the row that was pressed —
5018        // `old/target` is unpriced too and was not aimed at.
5019        assert_eq!(
5020            effect,
5021            Effect::Price(vec![PathBuf::from("/scan/nx/node_modules")])
5022        );
5023        assert!(view.notice().unwrap().contains("pricing 1 directory"));
5024
5025        // A subtree that is already priced says so rather than starting work with no result.
5026        // Opened on the way, because a press can only land on a row that is drawn — which is
5027        // also why an off-screen row starts nothing at all.
5028        point_at(&mut view, "/scan/nx/packages");
5029        let packages = at(&view, "/scan/nx/packages");
5030        assert_eq!(view.apply(Action::Price(packages)), Effect::None);
5031        assert!(
5032            view.notice().unwrap().contains("already carries a price"),
5033            "{:?}",
5034            view.notice()
5035        );
5036    }
5037
5038    #[test]
5039    fn a_double_click_on_a_row_that_has_gone_prices_nothing() {
5040        let mut tree = Tree::new("/scan");
5041        tree.insert(hit("/scan/old/target", Size::Unmeasured, 100));
5042        let mut view = View::new(tree);
5043        view.viewport(40);
5044        point_at(&mut view, "/scan/old");
5045        view.apply(Action::Expand);
5046        let target = at(&view, "/scan/old/target");
5047
5048        // The row was pressed and then deleted before the button came up. A detached node
5049        // keeps its hit, so "walk what is under this id" would happily hand back a path that
5050        // is no longer on screen and no longer on disk — which is the identity rule going
5051        // one way for the cursor and the other way for the work.
5052        view.removed(Path::new("/scan/old/target"), 0, true);
5053        settle(&mut view);
5054
5055        assert_eq!(view.apply(Action::Price(target)), Effect::None);
5056    }
5057
5058    #[test]
5059    fn a_subtree_already_being_priced_is_not_asked_for_a_second_time() {
5060        let mut tree = Tree::new("/scan");
5061        tree.insert(hit("/scan/nx/node_modules", Size::Unmeasured, 900));
5062        let mut view = View::new(tree);
5063        view.viewport(40);
5064        let nx = at(&view, "/scan/nx");
5065        let claim = PathBuf::from("/scan/nx/node_modules");
5066
5067        assert_eq!(
5068            view.apply(Action::Price(nx)),
5069            Effect::Price(vec![claim.clone()])
5070        );
5071
5072        // Leaning on the button during a traversal of a real `node_modules` would otherwise
5073        // queue the same traversal again and again. `Tree::price` rejecting the duplicate
5074        // *result* is no help: by then the expensive part has already happened.
5075        assert_eq!(view.apply(Action::Price(nx)), Effect::None);
5076        assert!(
5077            view.notice().unwrap().contains("already being priced"),
5078            "{:?}",
5079            view.notice()
5080        );
5081
5082        // …and the two facts stay apart once the pass reports: nothing left to ask for
5083        // because it has a price now, rather than because somebody is still working on it.
5084        view.priced(&claim, Size::Measured(64));
5085        view.repriced(&[claim], Notice::passing("priced 1 directory"));
5086        assert_eq!(view.apply(Action::Price(nx)), Effect::None);
5087        assert!(
5088            view.notice().unwrap().contains("already carries a price"),
5089            "{:?}",
5090            view.notice()
5091        );
5092    }
5093
5094    #[test]
5095    fn a_pricing_pass_that_never_reports_does_not_strand_its_rows() {
5096        let mut tree = Tree::new("/scan");
5097        tree.insert(hit("/scan/nx/node_modules", Size::Unmeasured, 900));
5098        let mut view = View::new(tree);
5099        view.viewport(40);
5100        let nx = at(&view, "/scan/nx");
5101        let claim = PathBuf::from("/scan/nx/node_modules");
5102        view.apply(Action::Price(nx));
5103
5104        // Handing the claims back is what the loop does when the worker has gone. Without
5105        // it the in-flight set leaks and the subtree can never be asked about again for the
5106        // rest of the run — a quiet, permanent no-op on a gesture the reader keeps making.
5107        view.repriced(
5108            std::slice::from_ref(&claim),
5109            Notice::passing("the pricing went away"),
5110        );
5111        assert_eq!(view.notice(), Some("the pricing went away"));
5112        assert_eq!(view.apply(Action::Price(nx)), Effect::Price(vec![claim]));
5113    }
5114
5115    #[test]
5116    fn a_double_click_never_prices_what_the_filter_is_hiding() {
5117        let mut tree = Tree::new("/scan");
5118        tree.insert(hit("/scan/nx/node_modules", Size::Unmeasured, 900));
5119        tree.insert(hit(
5120            "/scan/nx/packages/ui/node_modules",
5121            Size::Unmeasured,
5122            800,
5123        ));
5124        let mut view = View::new(tree);
5125        view.viewport(40);
5126        filter(&mut view, "ui/node_modules");
5127
5128        // The filter's own safety rule, kept: a row acts on what its number describes. A
5129        // price landing on a hidden claim would move a total the reader cannot see.
5130        let nx = at(&view, "/scan/nx");
5131        assert_eq!(
5132            view.apply(Action::Price(nx)),
5133            Effect::Price(vec![PathBuf::from("/scan/nx/packages/ui/node_modules")])
5134        );
5135    }
5136
5137    #[test]
5138    fn the_footer_stops_saying_a_price_is_being_worked_out_once_it_is() {
5139        let mut tree = Tree::new("/scan");
5140        tree.insert(hit("/scan/nx/node_modules", Size::Unmeasured, 900));
5141        let mut view = View::new(tree);
5142        let nx = at(&view, "/scan/nx");
5143        view.apply(Action::Price(nx));
5144        assert!(view.notice().unwrap().contains("pricing"));
5145
5146        view.repriced(
5147            &[PathBuf::from("/scan/nx/node_modules")],
5148            Notice::passing("priced 1 directory"),
5149        );
5150        assert_eq!(view.notice(), Some("priced 1 directory"));
5151    }
5152
5153    #[test]
5154    fn quitting_is_the_only_thing_that_ends_the_view() {
5155        let mut view = view();
5156        assert_eq!(view.apply(Action::Quit), Effect::Quit);
5157    }
5158
5159    #[test]
5160    fn quitting_cannot_end_a_view_that_is_half_way_through_a_removal() {
5161        let mut view = view();
5162        view.asking(&planned(&["/scan/old/target"]), &[]);
5163        view.apply(Action::Highlight(Turn::Next));
5164        view.apply(Action::Answer);
5165        assert!(view.is_deleting());
5166
5167        // Leaving would take the pool with it, and what is left on disk would be neither the
5168        // tree the reader had nor the one they asked for — with nothing to report which.
5169        assert_eq!(view.apply(Action::Quit), Effect::None);
5170        assert!(!view.wants_to_quit());
5171        assert!(view.notice().unwrap().contains("has to finish"));
5172
5173        // Pressing it again does not wear the rule down either.
5174        assert_eq!(view.apply(Action::Quit), Effect::None);
5175        assert!(!view.wants_to_quit());
5176
5177        // …and the keystroke is remembered rather than dropped: the loop leaves on the first
5178        // frame after the removal reports.
5179        view.deleted(Notice::passing("removed 10 B from 1 directory"), 10);
5180        assert!(view.wants_to_quit());
5181    }
5182
5183    #[test]
5184    fn a_view_that_was_never_asked_to_quit_does_not_want_to() {
5185        let mut view = view();
5186        assert!(!view.wants_to_quit());
5187        view.asking(&planned(&["/scan/old/target"]), &[]);
5188        view.apply(Action::Highlight(Turn::Next));
5189        view.apply(Action::Answer);
5190        view.deleted(Notice::passing("removed 10 B from 1 directory"), 10);
5191        assert!(!view.wants_to_quit());
5192    }
5193
5194    // ---- what moves, and what it is saying ---------------------------------------------
5195
5196    #[test]
5197    fn a_rolled_up_total_climbs_toward_what_arrived_rather_than_snapping_to_it() {
5198        let mut view = view();
5199        let start = Instant::now();
5200        view.animate(start);
5201        assert_eq!(view.drawn_total().bytes, 310);
5202
5203        view.found(hit("/scan/big/node_modules", Size::Measured(690), 1));
5204        view.animate(start + COUNT_UP / 2);
5205
5206        // The tree knows the answer immediately; the screen takes a moment to say it, and
5207        // that moment is the information — a number climbing fast is a scan finding fast,
5208        // which the count of directories beside it cannot express.
5209        assert_eq!(view.total().bytes, 1000);
5210        let climbing = view.drawn_total().bytes;
5211        assert!(climbing > 310 && climbing < 1000, "{climbing}");
5212        assert!(view.is_moving());
5213
5214        view.animate(start + COUNT_UP * 8);
5215        assert_eq!(view.drawn_total().bytes, 1000);
5216        assert!(
5217            !view.is_moving(),
5218            "a settled view is still asking for frames"
5219        );
5220    }
5221
5222    #[test]
5223    fn a_row_the_walk_has_just_found_is_lit_and_the_light_goes_out() {
5224        let mut view = view();
5225        let start = Instant::now();
5226        view.animate(start);
5227        // Nothing here was found while anybody was watching, so nothing is lit. A view that
5228        // opened onto a tree it already had would otherwise flash all of it at once.
5229        assert!(view.freshness(at(&view, "/scan/nx")).abs() < f64::EPSILON);
5230
5231        view.found(hit("/scan/late/node_modules", Size::Measured(1), 1));
5232        view.animate(start);
5233
5234        let late = at(&view, "/scan/late");
5235        assert!(view.freshness(late) > 0.9, "{}", view.freshness(late));
5236        assert!(view.freshness(at(&view, "/scan/nx")).abs() < f64::EPSILON);
5237
5238        view.animate(start + ARRIVAL * 2);
5239        assert!(view.freshness(late).abs() < f64::EPSILON);
5240    }
5241
5242    #[test]
5243    fn an_ancestor_whose_children_are_still_being_priced_says_its_number_is_a_floor() {
5244        let mut tree = Tree::new("/scan");
5245        tree.insert(hit("/scan/nx/a/node_modules", Size::Measured(4200), 1));
5246        tree.insert(hit("/scan/nx/b/node_modules", Size::Unmeasured, 1));
5247        let mut view = View::new(tree);
5248        let nx = at(&view, "/scan/nx");
5249
5250        // Not `4.1 KiB`, which would be wrong in the one direction a cleaner must not be
5251        // wrong in, and not a dash, which throws away a number that is already known.
5252        assert_eq!(view.roll(nx).label(), "> 4.1 KiB");
5253
5254        view.priced(Path::new("/scan/nx/b/node_modules"), Size::Measured(700));
5255        view.sync();
5256        assert_eq!(view.roll(nx).label(), "4.8 KiB");
5257    }
5258
5259    #[test]
5260    fn only_the_claims_a_pricing_thread_is_inside_are_hot() {
5261        let mut tree = Tree::new("/scan");
5262        tree.insert(hit("/scan/a/node_modules", Size::Unmeasured, 1));
5263        tree.insert(hit("/scan/b/node_modules", Size::Unmeasured, 1));
5264        let mut view = View::new(tree);
5265        let a = at(&view, "/scan/a/node_modules");
5266        let b = at(&view, "/scan/b/node_modules");
5267        assert!(!view.is_pricing(a) && !view.is_pricing(b));
5268
5269        view.pricing(Path::new("/scan/a/node_modules"));
5270
5271        // The whole claim the effect makes, and the reason it is worth an event of its own:
5272        // the pool is bounded, so as many rows are hot as there are threads working. `b` is
5273        // queued, which is a different fact about a dash and used to be indistinguishable.
5274        assert!(view.is_pricing(a));
5275        assert!(!view.is_pricing(b));
5276
5277        view.priced(Path::new("/scan/a/node_modules"), Size::Measured(64));
5278        assert!(!view.is_pricing(a));
5279    }
5280
5281    #[test]
5282    fn a_walk_that_has_finished_leaves_nothing_shimmering_for_a_thread_that_is_gone() {
5283        let mut tree = Tree::new("/scan");
5284        tree.insert(hit("/scan/a/node_modules", Size::Unmeasured, 1));
5285        let mut view = View::new(tree);
5286        let a = at(&view, "/scan/a/node_modules");
5287        view.pricing(Path::new("/scan/a/node_modules"));
5288
5289        view.scanned();
5290
5291        // A pool that has stopped can leave a claim hot if it died on the way. A row moving
5292        // for a thread that no longer exists is the one thing here that would say nothing.
5293        assert!(!view.is_pricing(a));
5294        assert!(!view.is_moving());
5295    }
5296
5297    #[test]
5298    fn a_claim_deleted_while_it_was_being_priced_stops_shimmering() {
5299        let mut view = view();
5300        let start = Instant::now();
5301        view.animate(start);
5302        view.pricing(Path::new("/scan/old/target"));
5303        assert!(view.is_moving());
5304
5305        // The price for this claim will never arrive, because `priced` resolves a path and
5306        // the path is gone. Nothing else would ever cool it: it would shimmer for a thread
5307        // that finished long ago, and hold the whole view at the animating frame rate to do
5308        // it — a quiet, permanent cost on a row nobody can see.
5309        view.removed(Path::new("/scan/old/target"), 10, true);
5310        view.animate(start + DIM);
5311
5312        assert!(view.tree().find(Path::new("/scan/old/target")).is_none());
5313        assert!(!view.is_moving());
5314    }
5315
5316    #[test]
5317    fn marking_a_row_runs_the_mark_up_its_ancestors_rather_than_flashing_all_of_them() {
5318        let mut view = view();
5319        let start = Instant::now();
5320        view.animate(start);
5321        point_at(&mut view, "/scan/nx");
5322        view.apply(Action::Mark);
5323        view.animate(start);
5324
5325        // The signature interaction, and the one whose effect is otherwise entirely off
5326        // screen: `nx` is collapsed, so everything the mark took is out of sight and the only
5327        // visible consequence is on ancestors the reader is not looking at.
5328        let root = view.tree().root();
5329        assert!(view.is_cascading(at(&view, "/scan/nx")));
5330        assert!(!view.is_cascading(root), "the whole chain flashed at once");
5331
5332        view.animate(start + RUNG);
5333        assert!(view.is_cascading(root), "the mark never reached the root");
5334
5335        view.animate(start + RUNG + FLASH);
5336        assert!(!view.is_cascading(root));
5337        assert!(!view.is_moving());
5338    }
5339
5340    #[test]
5341    fn a_partial_ancestor_says_what_share_of_its_bytes_is_marked() {
5342        let mut view = view();
5343        point_at(&mut view, "/scan/nx");
5344        view.apply(Action::Expand);
5345        point_at(&mut view, "/scan/nx/node_modules");
5346        view.apply(Action::Mark);
5347
5348        // 200 of `nx`'s 300 bytes, and 200 of the root's 310. A bare partial marker says
5349        // "some of this"; the share is what tells a reader whether opening the row is worth
5350        // the keystroke.
5351        assert!((view.share(at(&view, "/scan/nx")) - 200.0 / 300.0).abs() < 1e-9);
5352        assert!((view.share(view.tree().root()) - 200.0 / 310.0).abs() < 1e-9);
5353        assert!(view.share(at(&view, "/scan/old")).abs() < f64::EPSILON);
5354        assert!((view.share(at(&view, "/scan/nx/node_modules")) - 1.0).abs() < f64::EPSILON);
5355    }
5356
5357    #[test]
5358    fn a_share_that_cannot_be_stated_in_bytes_is_stated_in_claims() {
5359        let mut tree = Tree::new("/scan");
5360        tree.insert(hit("/scan/a/node_modules", Size::Measured(1000), 1));
5361        tree.insert(hit("/scan/b/node_modules", Size::Unmeasured, 1));
5362        let mut view = View::new(tree);
5363        point_at(&mut view, "/scan/a");
5364        view.apply(Action::Mark);
5365
5366        // By bytes this is 100% marked, which would read as "all but a sliver of this is
5367        // spoken for" — for a subtree whose one marked claim is the only one anybody has
5368        // measured. Claims are always known, so they are what the glyph reports until the
5369        // bytes can be trusted.
5370        assert!((view.share(view.tree().root()) - 0.5).abs() < 1e-9);
5371
5372        view.priced(Path::new("/scan/b/node_modules"), Size::Measured(1000));
5373        view.sync();
5374        assert!((view.share(view.tree().root()) - 0.5).abs() < 1e-9);
5375    }
5376
5377    // ---- deletion, restrained ---------------------------------------------------------
5378
5379    #[test]
5380    fn a_row_empties_on_the_bytes_the_deleter_says_have_gone() {
5381        let mut view = view();
5382        let start = Instant::now();
5383        view.animate(start);
5384        point_at(&mut view, "/scan/nx");
5385        view.apply(Action::Expand);
5386        let claim = at(&view, "/scan/nx/node_modules");
5387
5388        // Half of the 200-byte target is off the disk. Nothing about this is a timer: the
5389        // row is worth exactly what is left of it, and the next report decides the next
5390        // frame — so a target that takes ten seconds empties over ten seconds and one that
5391        // takes ten milliseconds does not pretend otherwise.
5392        view.freeing(Path::new("/scan/nx/node_modules"), 100);
5393        view.animate(start);
5394        assert!(view.is_freeing(claim));
5395        assert!(!view.is_spent(claim), "dimmed while it is still emptying");
5396        assert_eq!(view.drawn(claim).bytes, 100);
5397        // Its ancestors are lighter by the same bytes, on the same event.
5398        assert_eq!(view.drawn(at(&view, "/scan/nx")).bytes, 200);
5399
5400        view.freeing(Path::new("/scan/nx/node_modules"), 180);
5401        view.animate(start + Duration::from_millis(10));
5402        assert_eq!(view.drawn(claim).bytes, 20);
5403
5404        // The sweep finishes. Only now is the row dim, and only after the beat does it go.
5405        view.removed(Path::new("/scan/nx/node_modules"), 200, true);
5406        view.animate(start + Duration::from_millis(20));
5407        assert!(view.is_spent(claim));
5408        assert!(!view.is_freeing(claim));
5409        assert_eq!(view.drawn(claim).bytes, 0);
5410        assert!(
5411            view.tree()
5412                .find(Path::new("/scan/nx/node_modules"))
5413                .is_some()
5414        );
5415
5416        view.animate(start + Duration::from_millis(20) + DIM);
5417        assert!(
5418            view.tree()
5419                .find(Path::new("/scan/nx/node_modules"))
5420                .is_none()
5421        );
5422        assert_eq!(view.drawn(at(&view, "/scan/nx")).bytes, 100);
5423    }
5424
5425    #[test]
5426    fn a_row_the_sweep_could_not_finish_keeps_what_is_left_of_it() {
5427        let mut view = view();
5428        let start = Instant::now();
5429        view.animate(start);
5430        point_at(&mut view, "/scan/nx");
5431        view.apply(Action::Expand);
5432        let claim = at(&view, "/scan/nx/node_modules");
5433
5434        // A checkout inside it, an unreadable corner: the sweep went in, freed some of it and
5435        // came out again. The directory is still there, so the row stays — worth what is left
5436        // rather than what it was, which is the only figure that is true of the disk.
5437        view.removed(Path::new("/scan/nx/node_modules"), 150, false);
5438        view.animate(start + DIM * 2);
5439
5440        assert!(
5441            view.tree()
5442                .find(Path::new("/scan/nx/node_modules"))
5443                .is_some()
5444        );
5445        assert!(!view.is_spent(claim), "a row that survived was collapsed");
5446        assert_eq!(view.drawn(claim).bytes, 50);
5447    }
5448
5449    #[test]
5450    fn a_running_removal_counts_targets_against_the_batch_it_was_given() {
5451        let mut view = view();
5452        view.asking(
5453            &priced(&[
5454                ("/scan/nx/node_modules", 200),
5455                ("/scan/nx/packages/ui/node_modules", 100),
5456                ("/scan/old/target", 10),
5457            ]),
5458            &[],
5459        );
5460        view.apply(Action::Highlight(Turn::Next));
5461        view.apply(Action::Answer);
5462
5463        // The denominator is fixed here, by the question the reader answered — which is what
5464        // separates this bar from the pricing one, whose total grows as the walk finds claims.
5465        assert_eq!(view.removing().unwrap().counted(), (0, 3));
5466        assert_eq!(view.removing().unwrap().percent(), 0);
5467        // The batch's weight comes from the tree, so the denominator is there from the first
5468        // frame — which is the whole point of it. `2162 of 2188` cannot say whether the rest is
5469        // a second or an hour, and `0 B of 310 B` can.
5470        assert_eq!(view.removing().unwrap().weighed(), Some((0, 310)));
5471        assert_eq!(
5472            view.removing().unwrap().label(),
5473            "removing 0 of 3 directories · 0% · 0 B of 310 B"
5474        );
5475
5476        // The count moves on the deleter leaving a target, never on what it did there — the
5477        // row work is `removed`'s and the position is this. A removal reports both, in that
5478        // order, and only the second one advances the bar.
5479        view.removed(Path::new("/scan/nx/node_modules"), 200, true);
5480        assert_eq!(
5481            view.removing().unwrap().counted(),
5482            (0, 3),
5483            "the position moved on what happened to a row"
5484        );
5485        // The bytes are not the position and do move here: this target has given back what it
5486        // was worth, whatever the count says about where the pool is.
5487        assert_eq!(view.removing().unwrap().weighed(), Some((200, 310)));
5488        view.swept(Path::new("/scan/nx/node_modules"));
5489        assert_eq!(view.removing().unwrap().counted(), (1, 3));
5490
5491        // A target the sweep went into and came back out of counts the same: it is not a claim
5492        // that the target was removed — the row is still there saying what is left of it. Its
5493        // bytes count for what actually went, which is less than the plan expected of it.
5494        view.removed(Path::new("/scan/nx/packages/ui/node_modules"), 40, false);
5495        view.swept(Path::new("/scan/nx/packages/ui/node_modules"));
5496        assert_eq!(view.removing().unwrap().counted(), (2, 3));
5497        assert_eq!(view.removing().unwrap().percent(), 66);
5498        assert_eq!(view.removing().unwrap().weighed(), Some((240, 310)));
5499
5500        // The third target turns out to be gone already, so nothing happened to it and no row
5501        // moves — but the deleter still worked through it and said so, so the count reaches
5502        // its total rather than stopping one short for the rest of the run.
5503        view.swept(Path::new("/scan/old/target"));
5504        assert_eq!(view.removing().unwrap().counted(), (3, 3));
5505        assert_eq!(view.removing().unwrap().percent(), 100);
5506        // …and the bytes stop short, because they describe the outcome and it fell short. The
5507        // two figures disagreeing is them answering different questions, not a fault.
5508        assert_eq!(view.removing().unwrap().weighed(), Some((240, 310)));
5509
5510        view.deleted(Notice::passing("removed 240 B from 1 directory"), 240);
5511        assert!(view.removing().is_none());
5512        assert!(!view.is_deleting());
5513    }
5514
5515    #[test]
5516    fn the_footer_names_the_target_the_batch_is_waiting_on() {
5517        let mut view = view();
5518        view.asking(
5519            &priced(&[
5520                ("/scan/nx/node_modules", 200),
5521                ("/scan/nx/packages/ui/node_modules", 100),
5522                ("/scan/old/target", 10),
5523            ]),
5524            &[],
5525        );
5526        view.apply(Action::Highlight(Turn::Next));
5527        view.apply(Action::Answer);
5528
5529        // Nothing has started, so there is nothing to name. A footer that guessed here would be
5530        // naming a target the pool may not have reached.
5531        assert_eq!(view.removing().unwrap().busiest(), None);
5532
5533        // Two in flight at once, which is the normal state of a batch: the pool runs as many
5534        // targets as it has threads. The one worth naming is the larger — a target is swept by
5535        // a single thread, so it is the one that decides when the batch ends.
5536        view.freeing(Path::new("/scan/old/target"), 4);
5537        assert_eq!(
5538            view.removing().unwrap().busiest(),
5539            Some(Path::new("/scan/old/target"))
5540        );
5541        view.freeing(Path::new("/scan/nx/node_modules"), 8);
5542        assert_eq!(
5543            view.removing().unwrap().busiest(),
5544            Some(Path::new("/scan/nx/node_modules")),
5545            "the smaller target was named while a larger one was still going"
5546        );
5547
5548        // Weighed by what the plan expected rather than by what has gone, so the name does not
5549        // hand over the moment a big target gets ahead on bytes. `old/target` is worth 10 and
5550        // `nx/node_modules` 200, and the second stays named while it is still running.
5551        view.freeing(Path::new("/scan/old/target"), 10);
5552        assert_eq!(
5553            view.removing().unwrap().busiest(),
5554            Some(Path::new("/scan/nx/node_modules"))
5555        );
5556
5557        // The pool moves off it, and the name hands over to the largest still going rather
5558        // than sticking on a target that is finished.
5559        view.swept(Path::new("/scan/nx/node_modules"));
5560        assert_eq!(
5561            view.removing().unwrap().busiest(),
5562            Some(Path::new("/scan/old/target"))
5563        );
5564
5565        // And when the last one is done there is nobody left to be waiting on.
5566        view.swept(Path::new("/scan/old/target"));
5567        assert_eq!(view.removing().unwrap().busiest(), None);
5568    }
5569
5570    #[test]
5571    fn an_unpriced_batch_gives_no_byte_figure_rather_than_a_misleading_one() {
5572        // A default scan prices a fraction of what it finds, so a batch can be entirely
5573        // unpriced. The count still works — it never needed a size — and the byte pair is
5574        // withheld outright. Drawing `0 B of 0 B`, or a total that is quietly a fraction of the
5575        // truth, would be worse than saying nothing: a reader would read it as "nearly done".
5576        let mut view = View::new(Tree::new("/scan"));
5577        view.viewport(10);
5578        view.found(hit("/scan/app/node_modules", Size::Unmeasured, 0));
5579        view.asking(
5580            &[Planned::at("/scan/app/node_modules", Size::Unmeasured)],
5581            &[],
5582        );
5583        view.apply(Action::Highlight(Turn::Next));
5584        view.apply(Action::Answer);
5585
5586        assert_eq!(view.removing().unwrap().weighed(), None);
5587        assert_eq!(
5588            view.removing().unwrap().label(),
5589            "removing 0 of 1 directory · 0%"
5590        );
5591
5592        // The name still works, because what has been freed is the fallback ordering when
5593        // nothing priced the batch.
5594        view.freeing(Path::new("/scan/app/node_modules"), 512);
5595        assert_eq!(
5596            view.removing().unwrap().busiest(),
5597            Some(Path::new("/scan/app/node_modules"))
5598        );
5599    }
5600
5601    #[test]
5602    fn a_batch_that_fails_on_everything_still_shows_the_deleter_working_through_it() {
5603        let mut view = view();
5604        view.asking(
5605            &priced(&[
5606                ("/scan/nx/node_modules", 200),
5607                ("/scan/nx/packages/ui/node_modules", 100),
5608                ("/scan/old/target", 10),
5609            ]),
5610            &[],
5611        );
5612        view.apply(Action::Highlight(Turn::Next));
5613        view.apply(Action::Answer);
5614
5615        // Every target fails before unlinking a single entry, so not one of them is a removal
5616        // and not one row moves. The deleter is working through them all the same, and a bar
5617        // that read 0% for the whole run and then vanished would be reporting the OUTCOME
5618        // while claiming to report the position.
5619        for (done, path) in [
5620            "/scan/nx/node_modules",
5621            "/scan/nx/packages/ui/node_modules",
5622            "/scan/old/target",
5623        ]
5624        .iter()
5625        .enumerate()
5626        {
5627            view.swept(Path::new(path));
5628            assert_eq!(view.removing().unwrap().counted(), (done + 1, 3));
5629        }
5630        assert_eq!(view.removing().unwrap().percent(), 100);
5631        // The position reaches its total and the bytes stay at nothing, which is the pair
5632        // saying exactly what happened: the deleter went everywhere it was sent and came back
5633        // with nothing. A single bar weighted by bytes would have read 0% throughout and then
5634        // vanished, and one weighted by targets alone could not tell this from a batch that
5635        // freed 300 GiB.
5636        assert_eq!(view.removing().unwrap().weighed(), Some((0, 310)));
5637        assert_eq!(
5638            view.removing().unwrap().label(),
5639            "removing 3 of 3 directories · 100% · 0 B of 310 B"
5640        );
5641
5642        // …and nothing was deleted, which is the other half of the same claim: the position
5643        // says where the deleter got to and never that anything went.
5644        assert_eq!(view.roll(view.tree().root()).bytes, 310);
5645        assert_eq!(view.roll(view.tree().root()).claims, 3);
5646    }
5647
5648    #[test]
5649    fn a_second_removal_is_refused_while_one_is_running() {
5650        let mut view = view();
5651        view.asking(&planned(&["/scan/old/target"]), &[]);
5652        view.apply(Action::Highlight(Turn::Next));
5653        view.apply(Action::Answer);
5654
5655        point_at(&mut view, "/scan/nx");
5656        view.apply(Action::Mark);
5657        assert_eq!(view.apply(Action::Commit), Effect::None);
5658        assert_eq!(view.notice(), Some("a removal is already running"));
5659    }
5660
5661    #[test]
5662    fn a_part_emptied_row_does_not_spring_back_when_the_batch_reports() {
5663        let mut view = view();
5664        let start = Instant::now();
5665        view.animate(start);
5666        point_at(&mut view, "/scan/nx");
5667        view.apply(Action::Expand);
5668        let claim = at(&view, "/scan/nx/node_modules");
5669        let root = view.tree().root();
5670
5671        // 150 of the target's 200 bytes go, and then the sweep comes out again — a checkout
5672        // inside it, an unreadable corner. Both events, because that is the order the deleter
5673        // reports in and the reduction has to survive either one arriving last.
5674        view.freeing(Path::new("/scan/nx/node_modules"), 150);
5675        view.removed(Path::new("/scan/nx/node_modules"), 150, false);
5676        view.animate(start);
5677        assert_eq!(view.drawn(claim).bytes, 50);
5678        assert_eq!(view.drawn_total().bytes, 160);
5679
5680        // The batch reports. Its per-target figures are dropped for the report's own
5681        // arithmetic — and until this was fixed, the *reduction* went with them: the tree still
5682        // held 200 for a directory that has 50 left, so the row and the headline both jumped
5683        // back to what they were worth before a single byte was deleted. That is the direction
5684        // a cleaner may never be wrong in, because the number that rose is the one a reader
5685        // came back to check.
5686        // Standing, because this is the shape `summarise` gives a batch that left something
5687        // behind: the sweep came out of this target without finishing it.
5688        view.deleted(Notice::standing("freed 150 B"), 150);
5689        view.animate(start + DIM * 2);
5690
5691        assert_eq!(view.drawn(claim).bytes, 50, "the row sprang back");
5692        assert_eq!(view.drawn_total().bytes, 160, "the headline rose again");
5693        // Durably, not just on this frame: the tree itself is what a later sort, filter or
5694        // second batch reads, and it has to agree with the screen.
5695        assert_eq!(view.roll(claim).bytes, 50);
5696        assert_eq!(view.roll(root).bytes, 160);
5697        // And the freed figure is untouched — the bytes are counted once, by the batch.
5698        assert_eq!(view.drawn_freed(), 150);
5699    }
5700
5701    #[test]
5702    fn a_row_the_deleter_has_touched_is_out_of_the_batch_and_out_of_the_counter() {
5703        let mut view = view();
5704        let start = Instant::now();
5705        view.animate(start);
5706        point_at(&mut view, "/scan/nx");
5707        view.apply(Action::Mark);
5708        assert_eq!(view.marked().claims, 2);
5709
5710        // Part way through, not finished: the bytes are gone but the directory is not, so the
5711        // counter loses the bytes and keeps the count.
5712        view.freeing(Path::new("/scan/nx/node_modules"), 200);
5713        view.animate(start);
5714        assert_eq!(view.marked().claims, 2);
5715        assert_eq!(view.marked().bytes, 100);
5716        // It is out of the batch from the moment the sweep first touched it, though. Offering
5717        // a directory that is being deleted to a second removal would report a failure for
5718        // the one thing that worked.
5719        assert_eq!(
5720            batched(&view),
5721            [PathBuf::from("/scan/nx/packages/ui/node_modules")]
5722        );
5723
5724        // And the claim stops counting when the sweep says it has finished with it.
5725        view.removed(Path::new("/scan/nx/node_modules"), 200, true);
5726        view.animate(start);
5727        assert_eq!(view.marked().claims, 1);
5728        assert_eq!(view.marked().bytes, 100);
5729    }
5730
5731    #[test]
5732    fn a_row_the_deleter_has_touched_cannot_be_marked() {
5733        let mut view = view();
5734        let start = Instant::now();
5735        view.animate(start);
5736        point_at(&mut view, "/scan/old");
5737        view.apply(Action::Expand);
5738        point_at(&mut view, "/scan/old/target");
5739
5740        view.freeing(Path::new("/scan/old/target"), 5);
5741        view.animate(start);
5742        view.apply(Action::Mark);
5743
5744        // The cursor is still on it, because it is still on screen saying what is happening to
5745        // it. A mark aimed at a directory that is being deleted is a keystroke with nowhere
5746        // to land.
5747        assert_eq!(view.marked().claims, 0);
5748        assert!(view.batch().is_empty());
5749    }
5750
5751    #[test]
5752    fn the_freed_counter_climbs_on_the_same_bytes_the_reclaimable_one_loses() {
5753        // Its own tree, with sizes a disk would actually have: a chase snaps once it is
5754        // within a byte of its target, so a ten-byte counter arrives before it has moved.
5755        let mut tree = Tree::new("/scan");
5756        tree.insert(hit("/scan/nx/node_modules", Size::Measured(300_000), 900));
5757        tree.insert(hit("/scan/old/target", Size::Measured(100_000), 100));
5758        let mut view = View::new(tree);
5759        view.viewport(40);
5760        let start = Instant::now();
5761        view.animate(start);
5762        assert!(!view.has_freed());
5763        assert_eq!(view.drawn_total().bytes, 400_000);
5764
5765        // One event, both counters. They are not two accounts of a deletion that have to be
5766        // kept in step — they are one number read from each end, which is why they cannot
5767        // drift apart or lag one another.
5768        view.freeing(Path::new("/scan/old/target"), 40_000);
5769        view.animate(start + COUNT_UP * 8);
5770        assert!(view.has_freed());
5771        assert_eq!(view.drawn_total().bytes, 360_000);
5772        assert_eq!(view.drawn_freed(), 40_000);
5773
5774        view.removed(Path::new("/scan/old/target"), 100_000, true);
5775        view.animate(start + COUNT_UP * 16);
5776        assert_eq!(view.drawn_total().bytes, 300_000);
5777        assert_eq!(view.drawn_freed(), 100_000);
5778    }
5779
5780    #[test]
5781    fn the_batch_report_replaces_the_running_total_rather_than_adding_to_it() {
5782        let mut tree = Tree::new("/scan");
5783        tree.insert(hit("/scan/old/target", Size::Measured(100_000), 100));
5784        let mut view = View::new(tree);
5785        view.viewport(40);
5786        let start = Instant::now();
5787        view.animate(start);
5788
5789        view.freeing(Path::new("/scan/old/target"), 60_000);
5790        view.removed(Path::new("/scan/old/target"), 100_000, true);
5791        view.animate(start + COUNT_UP * 8);
5792        assert_eq!(view.drawn_freed(), 100_000);
5793
5794        // The batch's own arithmetic over the same bytes. Added to what the counter had
5795        // already climbed, this would read 200_000 — every byte counted twice, on the one
5796        // number a reader came back for.
5797        view.deleted(
5798            Notice::passing("removed 97.7 KiB from 1 directory"),
5799            100_000,
5800        );
5801        view.animate(start + COUNT_UP * 16);
5802        assert_eq!(view.drawn_freed(), 100_000);
5803
5804        // …and it survives the row finally collapsing away, which is what would drop the
5805        // per-target figures if they were still the ones being counted.
5806        view.animate(start + COUNT_UP * 16 + DIM * 2);
5807        assert_eq!(view.drawn_freed(), 100_000);
5808        assert!(view.tree().find(Path::new("/scan/old/target")).is_none());
5809    }
5810
5811    #[test]
5812    fn a_directory_the_safety_model_refused_says_so_on_its_own_row() {
5813        let mut view = view();
5814        view.refused(&[Refused {
5815            path: PathBuf::from("/scan/old/target"),
5816            reason: Refusal::HoldsCheckout,
5817        }]);
5818
5819        // The footer says how many were left alone and then moves on to the next thing. Only
5820        // the row can say *which*, and it is the tool working rather than a fault — so it is
5821        // kept apart from the walk's errors, which is what lets the renderer draw it calmly.
5822        assert_eq!(
5823            view.kept_reason(at(&view, "/scan/old/target")),
5824            Some("holds a git checkout")
5825        );
5826        assert_eq!(view.kept_reason(at(&view, "/scan/nx/node_modules")), None);
5827    }
5828
5829    // ---- what shows a precious file, and what a mark then takes -------------------------
5830
5831    /// A tree with an unrecoverable file parked under a directory worth deleting for its bytes.
5832    ///
5833    /// The shape the whole rule is about: a reader marks `nx` to get 200 bytes back, and whether
5834    /// the `.env` two levels down goes with it is decided by one thing only — whether the view
5835    /// they marked through was showing it.
5836    fn tree_with_an_env_file() -> Tree {
5837        let mut tree = Tree::new("/scan");
5838        tree.insert(sized(
5839            of_kind("/scan/nx/node_modules", Kind::Dependencies),
5840            200,
5841        ));
5842        tree.insert(sized(
5843            gitignored_file("/scan/nx/app/.env", Some(Kind::Unrecoverable)),
5844            40,
5845        ));
5846        tree.insert(sized(
5847            gitignored_file("/scan/nx/app/build.log", Some(Kind::Noise)),
5848            10,
5849        ));
5850        tree
5851    }
5852
5853    /// The same tree, on a view that has been told to show files.
5854    fn with_an_env_file() -> View {
5855        let mut view = View::new(tree_with_an_env_file());
5856        view.viewport(40);
5857        // Showing files, because a rule about what a mark takes has to be tested on a view
5858        // that can see what it is taking.
5859        view.apply(Action::ToggleFiles);
5860        view
5861    }
5862
5863    #[test]
5864    fn a_run_that_asked_for_files_on_the_command_line_opens_showing_them() {
5865        // The two front ends have to mean the same thing by `--ignored-files`. The walk claims
5866        // files either way, so without this the flag would be a silent no-op in the tree —
5867        // which is worse than not having it, because it reads as a request that was honoured.
5868        let mut tree = Tree::new("/scan");
5869        tree.insert(sized(
5870            gitignored_file("/scan/nx/app/.env", Some(Kind::Unrecoverable)),
5871            40,
5872        ));
5873
5874        let mut shut = View::new(tree);
5875        shut.viewport(40);
5876        assert_eq!(shut.total().claims, 0);
5877        assert_eq!(shut.out_of_view(), 1);
5878
5879        let mut open = View::new({
5880            let mut tree = Tree::new("/scan");
5881            tree.insert(sized(
5882                gitignored_file("/scan/nx/app/.env", Some(Kind::Unrecoverable)),
5883                40,
5884            ));
5885            tree
5886        })
5887        .showing_files();
5888        open.viewport(40);
5889        assert_eq!(open.total().claims, 1);
5890        assert_eq!(open.out_of_view(), 0);
5891    }
5892
5893    #[test]
5894    fn a_mark_on_a_parent_takes_every_visible_claim_under_it_precious_ones_included() {
5895        // **A mark is a statement about a subtree**, and there is no exception to it. An
5896        // earlier pass excepted the unrecoverable kind unless the mark sat at its exact depth,
5897        // which is the thing this asserts is gone: it would make the ancestor's fractional
5898        // glyph describe a set nobody could see, and the mark model would need a rule with no
5899        // visible spelling. What keeps a `.env` out of a batch is the lens — see the test
5900        // below — and once it is on screen it is a row like any other.
5901        let mut view = with_an_env_file();
5902        point_at(&mut view, "/scan/nx");
5903        view.apply(Action::Mark);
5904        view.sync();
5905
5906        let mut took = batched(&view);
5907        took.sort();
5908        assert_eq!(
5909            took,
5910            [
5911                PathBuf::from("/scan/nx/app/.env"),
5912                PathBuf::from("/scan/nx/app/build.log"),
5913                PathBuf::from("/scan/nx/node_modules"),
5914            ]
5915        );
5916        // The counter and the batch are one traversal, so the number a reader is shown agrees
5917        // with what the deed would take — the env file's 40 bytes included.
5918        assert_eq!(view.marked().claims, 3);
5919        assert_eq!(view.marked().bytes, 250);
5920    }
5921
5922    #[test]
5923    fn the_lens_a_mark_was_made_through_is_the_only_thing_holding_a_precious_file_back() {
5924        // **The whole safety design, as one assertion.** A run opens with files off, so a
5925        // reader who never pressed `i` cannot see a `.env` — and because a mark carries the
5926        // lens it was made through (#626), widening the view afterwards does not reach back
5927        // and add one. That is the same lever `default` already pulls on the gitignored tier,
5928        // and it is the reason no second flag and no special deletion path are needed.
5929        let mut view = View::new(tree_with_an_env_file());
5930        view.viewport(40);
5931        point_at(&mut view, "/scan/nx");
5932        view.apply(Action::Mark);
5933        view.sync();
5934
5935        assert_eq!(batched(&view), [PathBuf::from("/scan/nx/node_modules")]);
5936
5937        view.apply(Action::ToggleFiles);
5938        view.sync();
5939        assert_eq!(
5940            batched(&view),
5941            [PathBuf::from("/scan/nx/node_modules")],
5942            "widening the view changed what an existing mark covers"
5943        );
5944    }
5945
5946    #[test]
5947    fn a_precious_row_marked_on_its_own_is_an_ordinary_row() {
5948        // Same keys, same rules: the label names what the thing is and gates nothing.
5949        let mut view = with_an_env_file();
5950        point_at(&mut view, "/scan/nx/app/.env");
5951        view.apply(Action::Mark);
5952        view.sync();
5953
5954        assert_eq!(batched(&view), [PathBuf::from("/scan/nx/app/.env")]);
5955        assert_eq!(view.marked().claims, 1);
5956    }
5957
5958    #[test]
5959    fn a_precious_claim_arriving_under_a_mark_later_joins_it_like_any_other() {
5960        // Marks resolve on demand as the scan streams claims in, which is what makes "mark
5961        // this directory" mean the directory rather than the rows found so far. A claim of any
5962        // kind arriving under it is therefore covered on arrival — that is what the reader
5963        // asked for, and singling one kind out of it would be the exception this no longer has.
5964        let mut view = with_an_env_file();
5965        point_at(&mut view, "/scan/nx");
5966        view.apply(Action::Mark);
5967        view.sync();
5968        let before = view.marked().claims;
5969
5970        view.found(sized(
5971            gitignored_file("/scan/nx/deep/id_rsa", Some(Kind::Unrecoverable)),
5972            8,
5973        ));
5974        view.found(sized(of_kind("/scan/nx/deep/dist", Kind::Build), 5));
5975        view.sync();
5976
5977        assert_eq!(view.marked().claims, before + 2);
5978        assert!(batched(&view).contains(&PathBuf::from("/scan/nx/deep/id_rsa")));
5979    }
5980
5981    #[test]
5982    fn an_unrecoverable_entry_is_countable_on_the_confirmation() {
5983        // The confirmation is the last place a reader can change their mind, so what it holds
5984        // has to be distinguishable *there* rather than only styled differently in the tree.
5985        let mut view = with_an_env_file();
5986        view.asking(
5987            &[
5988                Planned::at("/scan/nx/app/.env", Size::Measured(40)),
5989                Planned::at("/scan/nx/node_modules", Size::Measured(200)),
5990            ],
5991            &[],
5992        );
5993
5994        let pending = view.pending().expect("the box is up");
5995        assert_eq!(pending.unrecoverable(), 1);
5996        // Listed first, because the vocabulary is ordered by what it costs to lose and the
5997        // listing groups by that order.
5998        assert_eq!(
5999            pending.entries()[0].path,
6000            PathBuf::from("/scan/nx/app/.env")
6001        );
6002        assert_eq!(pending.entries()[0].kind, Some(Kind::Unrecoverable));
6003    }
6004
6005    #[test]
6006    fn a_refused_unrecoverable_entry_is_not_what_the_warning_is_about() {
6007        // A line the safety model is leaving standing is not a line this warning is about,
6008        // and counting it would put a red sentence over a batch that takes nothing precious.
6009        let mut view = with_an_env_file();
6010        view.asking(
6011            &[Planned::at("/scan/nx/node_modules", Size::Measured(200))],
6012            &[Refused {
6013                path: PathBuf::from("/scan/nx/app/.env"),
6014                reason: Refusal::HoldsCheckout,
6015            }],
6016        );
6017
6018        assert_eq!(view.pending().expect("the box is up").unrecoverable(), 0);
6019    }
6020
6021    /// The batch, as paths, for the tests that are about *what* is in it.
6022    fn batched(view: &View) -> Vec<PathBuf> {
6023        view.batch().into_iter().map(|target| target.path).collect()
6024    }
6025
6026    /// Applies a filter the way a reader does.
6027    fn filter(view: &mut View, pattern: &str) {
6028        view.apply(Action::OpenFilter);
6029        for character in pattern.chars() {
6030            view.apply(Action::Type(character));
6031        }
6032        view.apply(Action::Submit);
6033    }
6034
6035    /// A resolved plan's worth of question, without needing a filesystem to resolve one.
6036    /// A resolved plan's worth of targets, without a filesystem to resolve one against.
6037    fn planned(targets: &[&str]) -> Vec<Planned> {
6038        targets
6039            .iter()
6040            .map(|path| Planned::at(*path, Size::Measured(10)))
6041            .collect()
6042    }
6043
6044    /// The same, for tests that care what each target is worth — the batch's weight is what
6045    /// the footer's byte figure is a fraction of, and a batch of equal targets cannot show
6046    /// that the largest one is the one being named.
6047    fn priced(targets: &[(&str, u64)]) -> Vec<Planned> {
6048        targets
6049            .iter()
6050            .map(|(path, bytes)| Planned::at(*path, Size::Measured(*bytes)))
6051            .collect()
6052    }
6053}
6054
6055#[cfg(test)]
6056mod scale {
6057    //! The spike the task asked for, kept so it can be re-run rather than believed.
6058    //!
6059    //! `pristine ~` on one real machine finds **16,013 claims across 22,765 directories**, and
6060    //! the shape that matters is not the total: one level is **8,660 wide**
6061    //! (`definitely-typed/types`, one `node_modules` per package). The question was whether a
6062    //! tree survives that, or whether "collapse everything above N children" has to be
6063    //! designed in before the layout is locked.
6064    //!
6065    //! It survives, and not narrowly. Fully expanded — 32,634 rows in the fixture below, which
6066    //! is deliberately larger than the real thing — a re-sort and a re-flatten of *everything*
6067    //! costs **1.5 ms** in release and 23 ms in debug, against a 100 ms frame. A row is never
6068    //! measured, only counted, so the cost is in the sort and the walk rather than in the
6069    //! drawing, and the drawing is bounded by the viewport whatever the tree does.
6070    //!
6071    //! So there is no fan-out cap, and the reason is measured rather than assumed. Collapsed
6072    //! by default was already the answer; the numbers say it did not need a second one.
6073    //!
6074    //! # What the motion costs, on the same fixture
6075    //!
6076    //! The frame rate doubles and a bit while something is moving, so the budget these have to
6077    //! fit inside is **33 ms** rather than 100. Release, fully expanded, all 32,634 rows:
6078    //!
6079    //! | | per frame |
6080    //! |---|---|
6081    //! | nothing arriving | **1 µs** |
6082    //! | a claim arriving, on a view that hides nothing | **782 µs** |
6083    //! | the same, on the narrowed view a run opens on | **1.7 ms** |
6084    //! | the same, everything marked | **1.9 ms** |
6085    //! | the same, one row spared out of it | **2.4 ms** |
6086    //! | the same, with a treemap on the screen | **+67 µs** |
6087    //!
6088    //! Six things worth reading off that. The interpolation itself is the first row — one
6089    //! microsecond, because it is one entry per row the *pane* drew and the pane is fifty rows
6090    //! whatever the tree holds. The second is #602's own number: the sort and the re-flatten of
6091    //! everything, which neither the animation nor the marks touched.
6092    //!
6093    //! The gap between the second row and the third is **what the narrowed default costs**, and
6094    //! it is the one number #626 had to measure rather than argue. A view that hides something
6095    //! has to be able to say what each row is worth *under it*, and a mark is a (directory,
6096    //! view) pair whose partial glyph is computed against the **current** view — so "what is
6097    //! visible and what of it is selected" is a walk of the whole tree once per frame that does
6098    //! any work. The run opens on `default`, which hides the gitignored tier, so that walk
6099    //! happens from the first frame: about 870 µs for 32,634 nodes, or 2.6% of the budget. On
6100    //! `all-ignored` or `all` with nothing marked the pass is skipped outright, which is the
6101    //! second row.
6102    //!
6103    //! **That walk is a `Vec` rather than a `HashMap`, and the difference was 3×.** The first
6104    //! version hashed on [`NodeId`] and cost 7.2 ms — four `SipHash`es per node, for a key that
6105    //! is already a dense arena index the tree never recycles. Indexing is what makes a
6106    //! whole-tree pass per frame affordable at all, and it is worth knowing before the next
6107    //! per-node cache is added.
6108    //!
6109    //! Sparing a row out of a marked-everything used to be the expensive case rather than a
6110    //! rounding error on it: the mark was pushed down onto every sibling along the path, one of
6111    //! those levels is 8,660 wide, and the per-frame fold then ran over 8,661 marks. #626
6112    //! replaced the push-down with a single exclusion, so it is now **1 mark and 1 exclusion** —
6113    //! which was a correctness change first (a push-down silently spares whatever streams in
6114    //! beside the spared row) and a performance one by accident.
6115    //!
6116    //! The last row is what #631 added, and it is the cheapest thing in the table for what it
6117    //! buys. [`View::map_stamp`] folds one FNV per node onto this same walk, which lets the
6118    //! treemap answer "has anything I draw changed" without laying the map out — 467 µs a
6119    //! frame, forever, replaced by 50 ns. It is folded here rather than kept beside the tree
6120    //! because the tree's own stamp is **lens-blind**, and a run opens on a view that hides a
6121    //! whole tier: those arrivals must not buy a megabyte of redraw. It is skipped entirely
6122    //! when there is no pane, which on most terminals is always.
6123    //!
6124    //! And a frame only pays any of this when something moved. `sync` runs the pass behind the
6125    //! same `stale` flag as the sort, so a reader sitting looking at a still tree pays the first
6126    //! row and nothing else.
6127    //!
6128    //! `cargo test --release --lib scale -- --ignored --nocapture` re-runs it.
6129
6130    use crate::fixture::priced;
6131    use crate::tree::{Order, Tree};
6132    use crate::tui::keymap::{Action, Motion, Turn};
6133    use crate::tui::state::View;
6134    use crate::tui::treemap::Maps;
6135    use std::time::Instant;
6136
6137    /// A tree shaped like the home directory the spike measured.
6138    fn home() -> Tree {
6139        let mut tree = Tree::new("/home");
6140        for n in 0..8_660 {
6141            tree.insert(priced(&format!("/home/types/p{n}/node_modules"), 1024));
6142        }
6143        for repo in 0..300 {
6144            for pkg in 0..20 {
6145                tree.insert(priced(
6146                    &format!("/home/repos/r{repo}/packages/p{pkg}/node_modules"),
6147                    4096,
6148                ));
6149            }
6150        }
6151        for n in 0..1_353 {
6152            tree.insert(priced(&format!("/home/cache/a/b/c/d/e{n}/target"), 512));
6153        }
6154        tree
6155    }
6156
6157    #[test]
6158    #[ignore = "a measurement rather than an assertion; timings are not a pass or a fail"]
6159    fn measure_a_home_directorys_worth_of_rows() {
6160        let tree = home();
6161        println!("nodes: {}, claims: {}", tree.len(), tree.claims());
6162
6163        let started = Instant::now();
6164        let mut view = View::new(tree);
6165        view.viewport(50);
6166        println!("open, collapsed:            {:?}", started.elapsed());
6167
6168        let started = Instant::now();
6169        view.apply(Action::Cursor(Motion::Top));
6170        // Twice: the root starts open, so the first `*` closes it and the second opens
6171        // everything underneath.
6172        view.apply(Action::ToggleSubtree);
6173        view.apply(Action::ToggleSubtree);
6174        println!(
6175            "expand everything:          {:?} -> {} rows",
6176            started.elapsed(),
6177            view.rows().len()
6178        );
6179
6180        let started = Instant::now();
6181        view.apply(Action::SortBy(Order::Path));
6182        println!("re-sort, fully expanded:    {:?}", started.elapsed());
6183
6184        let started = Instant::now();
6185        view.found(priced("/home/repos/late/node_modules", 1));
6186        view.sync();
6187        println!("one arrival, fully expanded: {:?}", started.elapsed());
6188    }
6189
6190    #[test]
6191    #[ignore = "a measurement rather than an assertion; timings are not a pass or a fail"]
6192    fn measure_what_one_animated_frame_costs() {
6193        // The budget the motion work had to fit inside, and the reason every effect is keyed
6194        // by node and advanced from the viewport rather than from the tree. The frame rate
6195        // goes to 33 ms while something is moving, so that — not 100 ms — is what these have
6196        // to fit in, and the interesting cases are the two per-frame folds that are *not*
6197        // bounded by the pane: the marks and the drains.
6198        let tree = home();
6199        let mut view = View::new(tree);
6200        view.viewport(50);
6201        view.apply(Action::Cursor(Motion::Top));
6202        view.apply(Action::ToggleSubtree);
6203        view.apply(Action::ToggleSubtree);
6204        let epoch = Instant::now();
6205        println!("rows: {}", view.rows().len());
6206
6207        let started = Instant::now();
6208        for tick in 1..=100 {
6209            view.animate(epoch + super::super::moving::COUNT_UP * tick);
6210        }
6211        println!("quiet frame:                {:?}", started.elapsed() / 100);
6212
6213        // A frame with something arriving on it, which is the only kind that does any work:
6214        // `sync` folds the marks and the drains only when the tree or the marks moved, so a
6215        // view a reader is sitting and looking at costs the line above and nothing more.
6216        let started = Instant::now();
6217        for tick in 101..=200u32 {
6218            view.found(priced(&format!("/home/repos/late{tick}/node_modules"), 1));
6219            view.animate(epoch + super::super::moving::COUNT_UP * tick);
6220        }
6221        println!("frame during a scan:        {:?}", started.elapsed() / 100);
6222
6223        // The same frame on a view that hides nothing, which is the one case that skips the
6224        // whole-tree pass. It is what the run *used* to open on, so the difference between
6225        // this line and the one above is exactly what the narrowed default costs per frame.
6226        showing(&mut view, super::Preset::All);
6227        let started = Instant::now();
6228        for tick in 201..=300u32 {
6229            view.found(priced(&format!("/home/repos/wide{tick}/node_modules"), 1));
6230            view.animate(epoch + super::super::moving::COUNT_UP * tick);
6231        }
6232        println!("frame, nothing hidden:      {:?}", started.elapsed() / 100);
6233
6234        // And the expensive one, which is the whole reason this is measured rather than
6235        // assumed. A mark is a (directory, view) pair resolved on demand, so "what is
6236        // selected" is a walk of the whole tree rather than a fold over a handful of marks —
6237        // one pass per frame that does any work, over 32,634 nodes.
6238        showing(&mut view, super::Preset::Default);
6239        view.apply(Action::MarkAll);
6240        let started = Instant::now();
6241        for tick in 301..=400u32 {
6242            view.found(priced(&format!("/home/repos/later{tick}/node_modules"), 1));
6243            view.animate(epoch + super::super::moving::COUNT_UP * tick);
6244        }
6245        println!("frame, everything marked:   {:?}", started.elapsed() / 100);
6246
6247        // Sparing one row out of that used to push the root's mark down onto every sibling
6248        // along the path — and one of those levels is 8,660 wide. An exclusion says the same
6249        // thing in one entry, so this is now the line above plus nothing.
6250        select(&mut view, "/home/types/p0/node_modules");
6251        view.apply(Action::Mark);
6252        println!(
6253            "marks and exclusions:       {} + {}",
6254            view.marks.len(),
6255            view.spared.len()
6256        );
6257        let started = Instant::now();
6258        for tick in 401..=500u32 {
6259            view.found(priced(&format!("/home/repos/spared{tick}/node_modules"), 1));
6260            view.animate(epoch + super::super::moving::COUNT_UP * tick);
6261        }
6262        println!("frame, one row spared:      {:?}", started.elapsed() / 100);
6263
6264        // …and with a view narrowing on top, which is the pass at its most expensive: every
6265        // claim is asked whether it survives the lens as well as whether a mark covers it.
6266        view.apply(Action::ToggleKind(crate::rules::Kind::Dependencies));
6267        let started = Instant::now();
6268        for tick in 501..=600u32 {
6269            view.found(priced(&format!("/home/repos/lens{tick}/node_modules"), 1));
6270            view.animate(epoch + super::super::moving::COUNT_UP * tick);
6271        }
6272        println!("frame, marked and narrowed: {:?}", started.elapsed() / 100);
6273
6274        // And the same again with a treemap on the screen, which is what [`View::map_stamp`]
6275        // adds: one FNV fold per node, on the pass that is already walking every one of them.
6276        // It buys the map the right to ask "has anything I draw changed" for nothing, on a
6277        // frame where the answer is usually no — see [`super::super::treemap`]. Off by default
6278        // and computed only when there is a pane, because most terminals never draw one.
6279        view.allow_maps(Maps::Can);
6280        let started = Instant::now();
6281        for tick in 601..=700u32 {
6282            view.found(priced(&format!("/home/repos/map{tick}/node_modules"), 1));
6283            view.animate(epoch + super::super::moving::COUNT_UP * tick);
6284        }
6285        println!("…the same, with a map up:   {:?}", started.elapsed() / 100);
6286    }
6287
6288    /// Presses `f` until the view is the one named.
6289    fn showing(view: &mut View, preset: super::Preset) {
6290        for _ in 0..super::Preset::ALL.len() {
6291            if view.preset() == Some(preset) {
6292                return;
6293            }
6294            view.apply(Action::CyclePreset(Turn::Next));
6295        }
6296        panic!("{preset} is not on the cycle");
6297    }
6298
6299    /// Puts the cursor on a row by walking to it, which is all a reader can do.
6300    fn select(view: &mut View, path: &str) {
6301        let want = std::path::PathBuf::from(path);
6302        let at = view
6303            .rows()
6304            .iter()
6305            .position(|row| view.tree().node(row.id).path == want)
6306            .expect("the fixture is fully expanded");
6307        view.apply(Action::Cursor(Motion::Top));
6308        for _ in 0..at {
6309            view.apply(Action::Cursor(Motion::Down));
6310        }
6311    }
6312}