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}