Skip to main content

pristine/tui/
mod.rs

1//! The rollup tree TUI: the front end the whole tool is for.
2//!
3//! # What it is
4//!
5//! The filesystem tree, pruned to paths that lead to something reclaimable, with every
6//! ancestor carrying the bytes recoverable *beneath* it — collapsed by default and drilled
7//! into on demand. A row's number is not "how big is this directory" but "how much do I get
8//! back by emptying this subtree", so `~/repos/archived` is one row worth 118 GB rather than
9//! forty rows a reader has to recognise as related.
10//!
11//! That is the thing neither reference implementation has. kondo's own README calls it
12//! "essentially `rm -rf` with a prompt", which is a decision per hit, and hits are what scale:
13//! one real home directory here holds 16,013 of them. npkill is better and still flat, so the
14//! row a reader actually wants does not exist and has to be assembled from forty selections;
15//! its range-select is npkill approximating a tree without having one.
16//!
17//! # Three moving parts, and the channel between them
18//!
19//! - The **walker** runs on its own thread and reports [`Found`] events. Rows appear as it
20//!   finds them, which is npkill's good idea and is *easier* on a tree: a new claim updates
21//!   ancestor totals in place instead of reordering a flat list.
22//! - The **view** ([`state::View`]) holds everything a keystroke can change. It never touches
23//!   the terminal or the filesystem, so every rule it has is a unit test.
24//! - The **deleter** runs a marked batch on a pool and reports each target as it finishes,
25//!   which is why the cursor is anchored to a path: rows vanish under it.
26//!
27//! All three meet on one channel, drained once per frame. The event loop blocks on the
28//! terminal with a short timeout rather than on the channel, so a scan that finds nothing for
29//! a second still repaints and a keystroke is never waiting behind a walk.
30//!
31//! # The TUI prices what it shows, and the CLI does not
32//!
33//! A default scan leaves claims [`crate::Size::Unmeasured`], because pricing one means enumerating
34//! the subtree the walk deliberately pruned at — 4.6 s against 55.8 s over one real `~/repos`.
35//! That is right for a listing you read once and wrong for a tree you steer by: unpriced, the
36//! rollup has nothing to roll up, and the headline question has no answer at any depth.
37//!
38//! So the TUI turns the breakdown on unless the command line has scoped it. It can afford to,
39//! and #618 is why: prices are computed on a pool and arrive as separate events, so the rows
40//! are on screen at 7.5 s while the numbers fill in behind them for the following minute. The
41//! reader marks and deletes throughout. `--breakdown-under <PATH>` still means what it says,
42//! for a reader who wants one subtree priced and the rest left alone — and a **double click**
43//! is the same request made afterwards, on the one row in front of the reader.
44//!
45//! # The pointer is generic, and that is the whole choice
46//!
47//! The alternative on the table was OSC 8 hyperlinks, which only ever express "open this path".
48//! A pointer gives row selection, click-to-expand, click-to-mark, the wheel and column-heading
49//! sorting from one hit test, and every later feature gets it for free. Three parts, mirroring
50//! the keyboard's three:
51//!
52//! - [`render::hit`] resolves a cell to a [`Spot`] against the frame that was drawn. That is
53//!   also where the routing lives — an overlay covers the screen it is over, so a press inside
54//!   one *cannot* reach the tree, without anything restating the order.
55//! - [`Pointer`] holds what one event cannot see about the events before it: what the press
56//!   landed on, whether it has moved since, and whether one landed here a moment ago.
57//! - [`keymap::pointer`] turns a gesture and a spot into an [`Action`], off a table the help
58//!   overlay reads too.
59//!
60//! A [`Spot::Row`] names its row by **identity** and never by position, which matters more here
61//! than in pua: rows re-sort as prices land and *vanish* as removals complete, and a press
62//! resolved to a position and acted on a moment later would delete the wrong subtree.
63
64pub mod chrome;
65pub mod keymap;
66pub mod lens;
67pub mod moving;
68pub mod render;
69pub mod state;
70pub mod treemap;
71
72use std::io::{self, Write};
73use std::path::PathBuf;
74use std::sync::Arc;
75use std::sync::mpsc::{Receiver, Sender, TryRecvError, channel};
76use std::thread::JoinHandle;
77use std::time::{Duration, Instant};
78
79use ignore::gitignore::Gitignore;
80use ratatui::crossterm::event::{
81    self, DisableMouseCapture, EnableMouseCapture, Event, MouseButton, MouseEventKind,
82};
83use ratatui::crossterm::execute;
84use ratatui::crossterm::terminal::{
85    EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
86};
87use ratatui::layout::Position;
88use ratatui::{Terminal, TerminalOptions, Viewport, backend::CrosstermBackend};
89
90use crate::delete::{Deleter, Planner, Removal, Step, Target};
91use crate::size::{Measurer, SizeMode, human};
92use crate::tree::Tree;
93use crate::walk::{Found, Priced, WalkOutcome, Walker};
94use crate::{Ruleset, WalkError};
95use chrome::{Chrome, Decor, Status};
96use keymap::{Action, Gesture, Motion, action_for, finish};
97use render::{Placed, Spot};
98use state::{Effect, Notice, View, plural};
99use treemap::{Drawn, Maps, Pane, Screen};
100
101/// How long the loop waits on the terminal before repainting anyway.
102///
103/// The frame rate while something is arriving, and nothing at all while the reader is
104/// thinking: `event::poll` returns the moment a key is pressed, so this only bounds how stale
105/// a *number* can be, not how long a keystroke waits.
106const TICK: Duration = Duration::from_millis(100);
107
108/// The same, while something on screen is actually moving.
109///
110/// Ten frames a second is enough to keep a number from going stale and not enough to make one
111/// climb smoothly, so the loop draws faster exactly when there is something to see and drops
112/// straight back to [`TICK`] when there is not — which is most of the time a reader spends in
113/// here, deciding. A frame is 1.5 ms in release against this budget, so the difference is a
114/// few percent of one core during a scan and nothing at all while one is being read.
115const FRAME: Duration = Duration::from_millis(33);
116
117/// How close together two presses on one row have to be to make a double click.
118const DOUBLE_CLICK: Duration = Duration::from_millis(400);
119
120/// Everything the front end needs from the command line.
121#[derive(Debug, Clone)]
122pub struct Options {
123    /// The directory to scan.
124    pub root: PathBuf,
125    /// The size floor for the fallback tier.
126    pub min_size: u64,
127    /// How hard to work for each claim's size. See the module docs for why the default here
128    /// is not the default for the listing.
129    pub size_mode: SizeMode,
130    /// Whether to stay on one filesystem — for the walk *and* for the planner, which is a
131    /// safety property rather than a tidiness one.
132    pub one_file_system: bool,
133    /// Keep anything touched more recently than this.
134    pub older_than: Option<Duration>,
135    /// Whether the view opens with gitignored files on screen.
136    ///
137    /// The walk claims them whatever this says — `i` is what a reader presses, and a key that
138    /// found nothing until the scan was run again would not be one. This is only where the lens
139    /// starts, so that `--ignored-files` means the same thing here as it does to the listing.
140    pub ignored_files: bool,
141    /// Paths the reader has said never to walk into. See [`crate::Walker::excludes`].
142    pub excludes: Arc<Gitignore>,
143}
144
145/// What one arriving event tells the view.
146enum Message {
147    Found(Found),
148    Scanned(WalkOutcome),
149    /// A removal reporting on itself as it happens: see [`crate::delete::Step`]. Carried
150    /// through rather than flattened, because the two halves land on the view differently —
151    /// bytes as they leave, the row when the sweep is finished with it.
152    Removing(Step),
153    Deleted(Box<Removal>),
154    /// A subtree the reader double-clicked, now that it has been priced.
155    ///
156    /// The prices themselves went out one at a time as [`Found::Priced`], exactly as the
157    /// walk's do. This carries the **claims the pass was holding** — the view is keeping them
158    /// as "being priced" and nothing else knows which ones this pass had — plus what it could
159    /// not read.
160    Repriced {
161        claims: Vec<PathBuf>,
162        errors: Vec<WalkError>,
163    },
164}
165
166/// What the run turns out to have been, for the exit status.
167///
168/// A live view says everything it knows on the screen — an unreadable path in the header, a
169/// failed removal in the footer — and none of that reaches a script. The listing's rule is
170/// that a run which could not do everything it was asked exits non-zero, and there is no
171/// reason for the tree to be the exception: the same person pipes the same tool into the same
172/// `&&` the next day.
173#[derive(Debug, Default)]
174pub struct Outcome {
175    /// Paths the walk could not read, so every total is a lower bound. The header says so.
176    pub errors: Vec<WalkError>,
177    /// Targets a removal could not finish. **Not** the ones it refused: a refusal is the
178    /// safety model working, which is exactly the distinction [`Removal::is_clean`] draws.
179    pub failures: usize,
180    /// What the session actually got back, across every batch it ran.
181    ///
182    /// Not part of the exit status — a run that freed nothing is a perfectly whole run — but
183    /// it is the number the reader who walked away came back for, so it is what the window
184    /// title says once there is one. See [`Status::Freed`].
185    pub freed: u64,
186}
187
188impl Outcome {
189    /// Whether everything the run was asked to do actually happened.
190    #[must_use]
191    pub fn whole(&self) -> bool {
192        self.errors.is_empty() && self.failures == 0
193    }
194}
195
196/// The terminal states this took, and the undoing of each.
197///
198/// A guard rather than a pair of calls at the end, because the two things that go wrong are
199/// both invisible until somebody is left with a broken terminal: an *early* failure between
200/// the setup steps skips a cleanup that had not been written yet, and a `?` on the first
201/// cleanup step skips the second. Each flag is set the moment its state is genuinely entered,
202/// so [`Restore::finish`] and [`Drop`] both undo exactly what happened and nothing else.
203///
204/// The [`Chrome`] is a **field** rather than a second guard beside this one, and that is the
205/// point of it being here: a window title, a taskbar bar and an open synchronized update are
206/// three more things a `?` can walk away from, and three guards that each undo a third of the
207/// terminal are three chances to miss one. One owner, one order — innermost first.
208///
209/// **Mouse reporting is here for the same reason and is the sharpest case of it.** A process
210/// that dies with capture on leaves a shell that answers every movement of the hand with
211/// escape gibberish, and nothing is still alive to notice. This guard covers all three ways
212/// out — the ordinary return, a `?`, and a panic, since [`Drop`] runs while the stack unwinds
213/// — so no separate panic hook is needed to reach it.
214#[derive(Debug)]
215struct Restore<W: Write> {
216    raw: bool,
217    alternate: bool,
218    mouse: bool,
219    chrome: Chrome<W>,
220    /// The treemap's image, which is the one piece of state here that lives in **another
221    /// program's memory**: a graphics image is stored by the terminal, so a process that
222    /// exits without deleting one leaves it there with nothing alive to notice.
223    screen: Screen<W>,
224}
225
226impl<W: Write> Restore<W> {
227    /// A guard that has taken nothing yet, holding the decorations it will hand back.
228    fn new(chrome: Chrome<W>, screen: Screen<W>) -> Self {
229        Self {
230            raw: false,
231            alternate: false,
232            mouse: false,
233            chrome,
234            screen,
235        }
236    }
237
238    /// Puts back what was taken, attempting **every** step and reporting the first refusal.
239    ///
240    /// `?` between the steps is the bug this replaces: a terminal that is out of raw mode and
241    /// still on the alternate screen, or the other way round, is no better than one that was
242    /// never restored, and the failing call says nothing about whether the next one would.
243    fn finish(&mut self) -> io::Result<()> {
244        // The image first, because it was written last and because it is the only one of
245        // these whose undoing is somebody else's memory rather than a mode of this terminal.
246        let mut first = self.screen.restore();
247        // Then the decorations: the one that matters most — closing a synchronized update —
248        // is the difference between a terminal that repaints and one that shows a frozen
249        // frame forever.
250        first = first.and(self.chrome.restore());
251        if std::mem::take(&mut self.mouse) {
252            first = first.and(execute!(io::stdout(), DisableMouseCapture));
253        }
254        if std::mem::take(&mut self.raw) {
255            first = first.and(disable_raw_mode());
256        }
257        if std::mem::take(&mut self.alternate) {
258            first = first.and(execute!(io::stdout(), LeaveAlternateScreen));
259        }
260        first
261    }
262}
263
264impl<W: Write> Drop for Restore<W> {
265    /// The path a `?` takes, and the one a panic takes. Nothing here can report a failure, so
266    /// nothing here tries — [`Restore::finish`] is what the ordinary return calls.
267    fn drop(&mut self) {
268        let _ = self.finish();
269    }
270}
271
272/// The removal thread, joined however the loop ends.
273///
274/// A guard rather than a `join` at the bottom of the loop, and the difference is every exit
275/// that is not the bottom of the loop: a draw that fails, a terminal that stops answering, a
276/// panic. Each of those returns through a `?` that no amount of care at the end can catch, and
277/// what it abandons is a thread half way through unlinking a directory tree the reader
278/// confirmed. Leaving `main` then kills it mid-batch — the same hazard `q` had, reached by a
279/// path nobody presses.
280///
281/// So there is no bare [`JoinHandle`] anywhere in the loop. Owning one *is* the guarantee.
282#[derive(Default)]
283struct Batch(Option<JoinHandle<()>>);
284
285impl Batch {
286    /// Takes over from whatever ran before, which the view guarantees has finished — joined
287    /// rather than dropped anyway, because dropping a handle detaches it and detaching is the
288    /// whole bug.
289    fn takes_over(&mut self, removal: JoinHandle<()>) {
290        self.join();
291        self.0 = Some(removal);
292    }
293
294    /// Waits for the removal, if there is one. A failure to join is a thread that panicked,
295    /// which the loop already learns about from `reap` — there is nothing to do here but stop
296    /// waiting.
297    fn join(&mut self) {
298        if let Some(removal) = self.0.take() {
299            let _ = removal.join();
300        }
301    }
302}
303
304impl Drop for Batch {
305    fn drop(&mut self) {
306        self.join();
307    }
308}
309
310/// The button that is down: what it landed on, and whether it has moved since.
311///
312/// Held rather than acted on, because "was this a click or a drag" is a question the press
313/// cannot answer about itself. Carrying the [`Spot`] as well is what makes the deferral honest
314/// rather than merely delayed: the release dispatches what the press was **aimed** at,
315/// resolved against the frame the reader was looking at when they aimed, rather than
316/// re-testing a screen that has moved since.
317#[derive(Clone, Copy, Debug)]
318struct Press {
319    /// What it landed on — the click's target.
320    spot: Spot,
321    /// Whether it completed a double click, judged when it landed.
322    ///
323    /// Then rather than at the release, because a double click is two *presses* close
324    /// together and it is the second one's timing that says so.
325    double: bool,
326    /// Whether it has moved since: a drag rather than a click.
327    ///
328    /// **Sticky**, and deliberately not "the pointer is somewhere else now": a hand that
329    /// wanders a cell and comes back has still dragged, and finishing that as a click would
330    /// open or mark a row on the way out of a gesture that was not one.
331    moved: bool,
332}
333
334/// What the loop knows about a mouse event that the event itself does not.
335///
336/// # Why the loop holds this rather than the view
337///
338/// Every field is a judgement about a **previous** event, which the one in hand cannot see: a
339/// `Drag` says where the pointer is now and not where it started, and a press's [`Spot`]
340/// belongs to an event that was handled and is gone. Keeping them here is what lets
341/// [`keymap::pointer`] stay a pure function of one gesture and one spot.
342///
343/// # The click happens on the **release**, because until then it is not one
344///
345/// A press that releases without moving is a click, and a press that moves is not — but which
346/// of the two it is is unknown when the button goes down. Acting at the press and merely
347/// declining to act again at the release is not enough, because the press's action has already
348/// happened: a drag beginning on a column heading re-sorts the tree under the hand that was
349/// about to select from it, and one beginning on an expander opens a subtree nobody aimed at.
350///
351/// So a press **aims and nothing else**, and everything it landed on is carried in [`Press`]
352/// until the gesture says what it was.
353#[derive(Debug, Default)]
354struct Pointer {
355    /// The spot the last press landed on, and when — the double click's other half.
356    last: Option<(Spot, Instant)>,
357    /// The button that is down, if one is.
358    press: Option<Press>,
359}
360
361impl Pointer {
362    /// What this mouse event means, given what it landed on.
363    ///
364    /// Takes the spot rather than hit-testing, which makes the ordering safe by construction:
365    /// there is no way to ask this without having resolved the event against the frame that
366    /// was drawn.
367    fn read(&mut self, kind: MouseEventKind, spot: Spot, now: Instant) -> Action {
368        match kind {
369            MouseEventKind::Down(MouseButton::Left) => {
370                let double = self.judge(spot, now);
371                self.press = Some(Press {
372                    spot,
373                    double,
374                    moved: false,
375                });
376                // Aiming only: see the note above for what this press *does*.
377                keymap::pointer(Gesture::Aim, spot)
378            }
379            // A press that moved is a drag, not a click, and cannot be half of a double one
380            // either. Without the second half, dragging out a selection and then pressing
381            // where the drag started would toggle a subtree the reader was aiming to select
382            // from.
383            MouseEventKind::Drag(MouseButton::Left) => {
384                self.last = None;
385                if let Some(press) = self.press.as_mut() {
386                    press.moved = true;
387                }
388                Action::Ignore
389            }
390            // The release is judged against the press it ends, and only then is the press
391            // forgotten: a gesture is not over until the button is up.
392            MouseEventKind::Up(MouseButton::Left) => match self.press.take() {
393                Some(press) if !press.moved => finish(press.spot, press.double, spot),
394                // A drag's release, or a button that went down before pristine was
395                // reporting. Neither is a click.
396                _ => Action::Ignore,
397            },
398            // Over an answer this is the hover highlight; everywhere else it must stay free.
399            // Capture asks the terminal for *every* movement of the pointer, so anything that
400            // acted here would act at the speed of a hand crossing the window.
401            MouseEventKind::Moved => keymap::pointer(Gesture::Aim, spot),
402            MouseEventKind::ScrollUp => keymap::pointer(Gesture::Wheel(Motion::Up), spot),
403            MouseEventKind::ScrollDown => keymap::pointer(Gesture::Wheel(Motion::Down), spot),
404            // The two buttons pristine does not use, and their drags.
405            _ => Action::Ignore,
406        }
407    }
408
409    /// Whether this press completes a double click.
410    ///
411    /// It compares **spots**, and that is this method's whole correctness. A double click is a
412    /// claim about *what* was pressed twice, and on this screen the cell and the thing are not
413    /// the same question: rows re-sort as prices land and vanish as removals finish, so
414    /// between two presses 200 ms apart the cell under a steady finger can come to hold a
415    /// different directory. Judged on coordinates, the second press would then price — or, on
416    /// a row whose zone changed under it, open — something the reader never aimed at.
417    ///
418    /// Two consequences, both wanted. **A row is the target, not a cell of it**: two presses
419    /// anywhere on one row are a double, because [`keymap::Target`] collapses the zones for
420    /// everything but a click. And **a row that moved is a different target**: the press that
421    /// lands on another directory after a re-sort is a first press, and selects.
422    fn judge(&mut self, spot: Spot, now: Instant) -> bool {
423        let double = self.last.is_some_and(|(before, when)| {
424            before == spot && now.saturating_duration_since(when) <= DOUBLE_CLICK
425        });
426        // A completed double starts over rather than arming the next press: without this,
427        // leaning on the button prices a row over and over, and a triple click is two doubles.
428        self.last = (!double).then_some((spot, now));
429        double
430    }
431}
432
433/// Where keystrokes come from.
434///
435/// A seam with exactly one implementation in the shipped binary, and it exists so the event
436/// loop can be tested at all: the guarantees worth having here are about what happens when a
437/// *terminal* fails half way through a removal, and a test cannot press a key on a real one.
438trait Events {
439    /// Whether an event is waiting, within `timeout`.
440    fn poll(&mut self, timeout: Duration) -> io::Result<bool>;
441
442    /// The next event. Only called once [`Events::poll`] has said there is one.
443    fn read(&mut self) -> io::Result<Event>;
444}
445
446/// The real terminal.
447struct Keyboard;
448
449impl Events for Keyboard {
450    fn poll(&mut self, timeout: Duration) -> io::Result<bool> {
451        event::poll(timeout)
452    }
453
454    fn read(&mut self) -> io::Result<Event> {
455        event::read()
456    }
457}
458
459/// Runs the view until the reader quits, then puts the terminal back.
460///
461/// # Errors
462///
463/// Anything the terminal refuses. A failure to *restore* it is reported even when the run
464/// itself succeeded, because a terminal left in raw mode is the one outcome a user cannot
465/// ignore and cannot easily undo.
466pub fn run(options: &Options, ruleset: Arc<Ruleset>) -> io::Result<Outcome> {
467    // Detected once, from the environment and a `is_terminal`, and never probed for: see
468    // [`chrome`] for why nothing here asks the terminal a question it has to wait for.
469    let decor = Decor::detect();
470    let mut restore = Restore::new(
471        Chrome::new(io::stdout(), decor),
472        Screen::new(io::stdout(), decor.graphics),
473    );
474    enable_raw_mode()?;
475    restore.raw = true;
476    execute!(io::stdout(), EnterAlternateScreen)?;
477    restore.alternate = true;
478    execute!(io::stdout(), EnableMouseCapture)?;
479    restore.mouse = true;
480    restore.chrome.enter()?;
481
482    let mut terminal = Terminal::with_options(
483        CrosstermBackend::new(io::stdout()),
484        TerminalOptions {
485            viewport: Viewport::Fullscreen,
486        },
487    )?;
488    let outcome = drive(
489        &mut terminal,
490        &mut Keyboard,
491        &mut restore.chrome,
492        &mut restore.screen,
493        options,
494        ruleset,
495    );
496    // The taskbar says how the run ended as well as how far it got. The reader who wanted a
497    // bar is by definition the one who is not watching the exit status go past.
498    if !outcome.as_ref().is_ok_and(Outcome::whole) {
499        restore.chrome.failed();
500    }
501    // Cursor position is the terminal's business and the alternate screen swallowed it.
502    let shown = terminal.show_cursor();
503    let restored = restore.finish();
504    outcome.and_then(|outcome| shown.and(restored).map(|()| outcome))
505}
506
507/// The event loop, with the terminal already set up.
508fn drive<B: ratatui::backend::Backend<Error = io::Error>, W: Write>(
509    terminal: &mut Terminal<B>,
510    events: &mut dyn Events,
511    chrome: &mut Chrome<W>,
512    screen: &mut Screen<W>,
513    options: &Options,
514    ruleset: Arc<Ruleset>,
515) -> io::Result<Outcome> {
516    let (post, inbox) = channel();
517    let mut view = View::new(Tree::new(&options.root));
518    if options.ignored_files {
519        view = view.showing_files();
520    }
521    let walker = spawn_walk(options, ruleset, post.clone());
522    let mut outcome = Outcome::default();
523    // Dropped at the end of this function however it ends — the bottom of the loop, a `?`, or
524    // a panic — and dropping it waits for the removal. See [`Batch`].
525    let mut batch = Batch::default();
526    // Two clocks rather than one, because either phase can finish while the other is running:
527    // a reader can mark and delete a subtree the moment it appears, long before the walk is
528    // over, and a scan timed from the start of the removal it overlapped would be a scan
529    // reported as having taken seconds.
530    let scan_since = Instant::now();
531    let mut batch_since = Instant::now();
532    let mut scanning = view.is_scanning();
533    let mut deleting = view.is_deleting();
534    // Where the last frame put everything a press can land on, and what the loop knows about
535    // a gesture that the event in hand does not. Both start empty, which is the honest
536    // description of a run that has not drawn yet: a press before the first frame resolves to
537    // `Spot::Nowhere` and does nothing.
538    let mut placed = Placed::default();
539    let mut pointer = Pointer::default();
540    // The pricing worker's queue, started by the first double click that needs one and never
541    // replaced: one `Option` here *is* the guarantee that a run has at most one of them, the
542    // same way owning the removal's handle is the guarantee it gets joined.
543    let mut pricer: Option<Sender<Vec<PathBuf>>> = None;
544
545    loop {
546        drain(&mut view, &inbox, &mut outcome);
547        reap(&mut view, &inbox, &mut outcome, batch.0.as_ref());
548        // The one place a clock enters the view, and it syncs on the way through. Every
549        // interpolated number on screen moves exactly once per frame, here.
550        view.animate(Instant::now());
551
552        // A phase that has just ended is the only thing worth interrupting anybody for, and
553        // the chrome decides whether it is: long enough to matter, and nobody watching. Both
554        // of these mirror the view rather than being told separately when a phase begins —
555        // two records of one fact is how a notification ends up describing a removal that is
556        // still running.
557        if scanning && !view.is_scanning() {
558            chrome.announce(&scanned(&view), scan_since.elapsed())?;
559        }
560        if deleting && !view.is_deleting() {
561            // The footer's own sentence, so a notification can never describe a removal
562            // differently from the screen behind it.
563            let said = view.notice().unwrap_or("the removal finished").to_owned();
564            chrome.announce(&said, batch_since.elapsed())?;
565        }
566        scanning = view.is_scanning();
567        deleting = view.is_deleting();
568        chrome.show(Status::of(&view, outcome.freed))?;
569
570        // Both of the map's gates, in one answer, **before** the layout reads it. #656 was
571        // this asked in two places at two times: the allowlist here and the pixel size after
572        // the draw, so a terminal that passed one and failed the other had columns taken from
573        // its tree with nothing ever drawn in them. The same `cell` goes on to the pane below,
574        // so there is one reading of the window per frame and nothing to disagree with.
575        //
576        // Per frame rather than once, because only half of it is a constant: the allowlist is
577        // a fact about the program at the other end, and the pixel size is a fact about the
578        // window, which a tmux client attaching takes away mid-run.
579        let cell = cell_size(terminal);
580        view.allow_maps(screen.mapping(cell));
581
582        chrome.begin_frame()?;
583        // The geometry comes back out of the draw rather than being computed beside it, so a
584        // press is resolved against the frame the reader is looking at. See [`Placed`].
585        // The frame is dropped rather than kept: a `CompletedFrame` borrows the terminal,
586        // and the image below has to be written *through* the same one.
587        let drawn = terminal
588            .draw(|frame| placed = render::draw(frame, &mut view, &outcome.errors))
589            .map(|_| ());
590        // Inside the synchronized update, after the cells and before the frame is closed:
591        // the image and the text around it have to land together or the pane tears in a way
592        // a full repaint cannot fix, because ratatui will not redraw cells it did not change.
593        let mapped = map(screen, &view, &placed, cell);
594        // Ended before the draw's failure is reported, never after: a terminal left inside a
595        // synchronized update keeps showing the frame before last, so a `?` here would trade
596        // a reported error for a screen that is silently frozen.
597        let ended = chrome.end_frame();
598        let shown = drawn.and(mapped)?;
599        ended?;
600        // The screen has actually tried to draw, where the layout only asked — so when the two
601        // disagree the screen is believed. It cannot happen from the reading above, and that
602        // is the point: if a later change ever puts the two gates back out of step, the pane
603        // comes off the next frame with a sentence under it, instead of being an empty
604        // rectangle nobody can explain. That silence is the whole of what #656 was.
605        if let Drawn::Cannot(why) = shown {
606            view.allow_maps(why);
607        }
608
609        // A quit that was held back while a removal ran, now that it is over. Checked here
610        // rather than where the key was pressed, because what the key produced was a promise
611        // to leave and this is the first frame on which it can be kept.
612        if view.wants_to_quit() {
613            break;
614        }
615        if !events.poll(if view.is_moving() { FRAME } else { TICK })? {
616            continue;
617        }
618        let event = events.read()?;
619        let action = match &event {
620            // A resize is not a keystroke and produces no action; the redraw above is the
621            // whole of what it needs. The geometry it invalidates is replaced by that
622            // redraw before the next event is read.
623            Event::Resize(..) => continue,
624            // Focus reporting was asked for by the chrome and is answered to it: it is the
625            // one thing that can tell a notification whether anybody is there to read it.
626            Event::FocusGained => {
627                chrome.focused(true);
628                continue;
629            }
630            Event::FocusLost => {
631                chrome.focused(false);
632                continue;
633            }
634            // Resolved to a *spot* first and to an action second, which is what keeps the
635            // layout's order in one place: see [`render::hit`].
636            Event::Mouse(mouse) => {
637                let spot = render::hit(&view, &placed, Position::new(mouse.column, mouse.row));
638                pointer.read(mouse.kind, spot, Instant::now())
639            }
640            _ => action_for(&event, view.overlay()),
641        };
642        match view.apply(action) {
643            Effect::None => {}
644            // Only ever reached with nothing in flight: the view holds a quit back while it
645            // is deleting, and hands it over through `wants_to_quit` instead.
646            Effect::Quit => break,
647            Effect::Plan(targets) => {
648                let plan = Planner::new(&options.root)
649                    .one_file_system(options.one_file_system)
650                    .older_than(options.older_than)
651                    .plan(targets);
652                view.ask(&plan);
653            }
654            Effect::Delete(targets) => {
655                batch_since = Instant::now();
656                batch.takes_over(spawn_delete(options, targets, post.clone()));
657            }
658            // Queued onto the one pricing worker, started on the first gesture that needs
659            // one — see [`spawn_pricer`] for why there is exactly one of these.
660            Effect::Price(claims) => {
661                let queue = pricer.get_or_insert_with(|| spawn_pricer(options, post.clone()));
662                if let Err(returned) = queue.send(claims) {
663                    // The worker died — nothing in it should panic, and if one ever does the
664                    // cost is not the panic: the view is holding these claims as "being
665                    // priced", and left there the subtree can never be asked about again for
666                    // the rest of the run. Handing them back is the same shape of repair
667                    // `reap` makes for a removal that ended without reporting.
668                    //
669                    // Deliberately not an exit-status failure. Pricing is what a row *shows*,
670                    // not something the run was asked to accomplish — a claim left unpriced
671                    // reads as a dash, which is exactly the state `--breakdown-under` leaves
672                    // most of the tree in and exits zero on.
673                    view.repriced(
674                        &returned.0,
675                        Notice::standing("the pricing ended without reporting what it did"),
676                    );
677                    pricer = None;
678                }
679            }
680        }
681    }
682
683    // The walk holds a `Sender`; dropping ours and letting the thread finish is what stops a
684    // half-written frame from being the last thing on the screen. It is deliberately NOT
685    // joined, where the removal is: a walk reads, so abandoning one costs nothing, and a
686    // reader who pressed `q` during a scan of a home directory has said they are done
687    // waiting.
688    drop(walker);
689    Ok(outcome)
690}
691
692/// What one cell of this terminal measures in pixels, or `None` when it will not say.
693///
694/// **A terminal that refuses `TIOCGWINSZ`, or fills its pixel fields with zeros, is not an
695/// error.** The whole feature is an enhancement, so every way it can fail has to end in there
696/// being no picture rather than in a run that stops — which is why this is read with `ok()`
697/// where nearly every other call in this file carries a `?`.
698///
699/// Zeros are `None` and never `Some((0, 0))`, because they are an *absent* measurement rather
700/// than a small one, and the only two things to do with an absent cell size are to say so or
701/// to guess. Guessing draws an image at the wrong scale over the text it is meant to sit
702/// beside, which is the same class of wrong as pricing a directory nobody measured.
703fn cell_size<B: ratatui::backend::Backend<Error = io::Error>>(
704    terminal: &mut Terminal<B>,
705) -> Option<(u16, u16)> {
706    let window = terminal.backend_mut().window_size().ok()?;
707    let (across, down) = (window.columns_rows.width, window.columns_rows.height);
708    if across == 0 || down == 0 {
709        return None;
710    }
711    let cell = (window.pixels.width / across, window.pixels.height / down);
712    (cell.0 > 0 && cell.1 > 0).then_some(cell)
713}
714
715/// Puts the treemap on the frame that has just been drawn, or takes it off.
716///
717/// Three reasons it comes off, and they are all "the map cannot be right", not "the map is
718/// not wanted": there is no pane on this frame, an overlay is over the place it would go, or
719/// the terminal will not say how big a cell is.
720///
721/// `cell` is the same reading the layout was gated on this frame — see [`cell_size`] — rather
722/// than a second call to the terminal. Two readings are two answers that can differ, and a
723/// pane sized from one while the decision to reserve it was taken on the other is #656 again
724/// in a smaller window.
725fn map<W: Write>(
726    screen: &mut Screen<W>,
727    view: &View,
728    placed: &Placed,
729    cell: Option<(u16, u16)>,
730) -> io::Result<Drawn> {
731    // An overlay is drawn by ratatui *as cells*, and the image sits above them — so a help
732    // page over the map would be a help page behind it. The picture goes away instead.
733    let Some(cells) = placed.map.filter(|_| view.overlay().is_none()) else {
734        screen.hide()?;
735        return Ok(Drawn::Nothing);
736    };
737    let Some(cell) = cell else {
738        screen.hide()?;
739        return Ok(Drawn::Cannot(Maps::Unmeasured));
740    };
741    screen.show(view, Pane { cells, cell }, Instant::now())
742}
743
744/// Notices a removal that ended without saying what it did.
745///
746/// Nothing in the deleter should panic, and if one ever does the cost is not the panic: the
747/// view would sit `is_deleting()` forever, and a view that will not quit is worse than the
748/// thing that made it. The thread posts its report *before* it finishes, so a finished thread
749/// with nothing on the channel really has gone without reporting.
750fn reap(
751    view: &mut View,
752    inbox: &Receiver<Message>,
753    outcome: &mut Outcome,
754    deleter: Option<&JoinHandle<()>>,
755) {
756    if !view.is_deleting() || !deleter.is_some_and(JoinHandle::is_finished) {
757        return;
758    }
759    drain(view, inbox, outcome);
760    if view.is_deleting() {
761        outcome.failures += 1;
762        // The freed total is left where it stands: a removal that said nothing is a removal
763        // that gave no figure, and inventing one is the opposite of what this branch is for.
764        // Standing, for the same reason: a thread that died mid-batch is counted as a failure
765        // on the line above, and this sentence is where a reader learns of it.
766        view.deleted(
767            Notice::standing("the removal ended without reporting what it did"),
768            outcome.freed,
769        );
770    }
771}
772
773/// Empties the channel into the view.
774///
775/// Everything on it, not one message: a breakdown reports 16,013 claims and as many prices,
776/// and taking one per frame would render a tree that fills up over four minutes.
777fn drain(view: &mut View, inbox: &Receiver<Message>, outcome: &mut Outcome) {
778    loop {
779        match inbox.try_recv() {
780            Ok(Message::Found(Found::Claim(hit))) => view.found(hit),
781            Ok(Message::Found(Found::Pricing(path))) => view.pricing(&path),
782            Ok(Message::Found(Found::Priced(priced))) => view.priced(&priced.path, priced.size),
783            Ok(Message::Scanned(walk)) => {
784                outcome.errors.extend(walk.errors);
785                view.scanned();
786            }
787            // Both halves reach the view, and both carry the deleter's own running byte
788            // total — which is what lets a row's number fall on bytes that have genuinely
789            // left the disk rather than on a timer started once they already had.
790            Ok(Message::Removing(Step::Freeing(freeing))) => {
791                view.freeing(&freeing.path, freeing.bytes);
792            }
793            Ok(Message::Removing(Step::Finished(removed))) => {
794                view.removed(&removed.path, removed.bytes, removed.complete);
795            }
796            // Where the batch has got to, which is a different question from what happened to
797            // any row — a target that failed before unlinking anything still counts here.
798            Ok(Message::Removing(Step::Swept(path))) => view.swept(&path),
799            Ok(Message::Repriced { claims, errors }) => {
800                // The walk's rule, kept: a pass that could not read everything makes every
801                // total it fed a lower bound, and the header says so beside the numbers.
802                outcome.errors.extend(errors);
803                view.repriced(
804                    &claims,
805                    Notice::passing(format!(
806                        "priced {}",
807                        plural(claims.len(), "directory", "directories")
808                    )),
809                );
810            }
811            Ok(Message::Deleted(removal)) => {
812                outcome.failures += removal.failures.len();
813                outcome.freed += removal.bytes_freed();
814                // The rows the safety model left standing, so each one can say so where it
815                // is. The footer's count says how many; only the row can say which.
816                view.refused(&removal.kept);
817                // The batch's own arithmetic replaces the per-target figures the counter has
818                // been climbing on, rather than being added to them: they are the same bytes
819                // counted by the same code, so adding would double every one of them.
820                view.deleted(summarise(&removal), outcome.freed);
821            }
822            // Disconnected as well as empty: the walk finishing drops its sender, and there
823            // is nothing left to say either way.
824            Err(TryRecvError::Empty | TryRecvError::Disconnected) => return,
825        }
826    }
827}
828
829/// Starts the walk. The returned handle is only held so its `Sender` outlives the loop.
830fn spawn_walk(options: &Options, ruleset: Arc<Ruleset>, post: Sender<Message>) -> JoinHandle<()> {
831    let walker = Walker::new(&options.root, ruleset)
832        .size_mode(options.size_mode.clone())
833        .same_file_system(options.one_file_system)
834        // Always claimed here, and hidden by the lens rather than by the walk. The two are
835        // different questions — whether to pay to find them, and whether to draw them — and
836        // the tree is the one front end that can answer the second per keystroke. A walk that
837        // had left them out would make `i` a key that finds nothing until the run is done
838        // again.
839        .ignored_files(true)
840        .min_size(options.min_size)
841        .excludes(Arc::clone(&options.excludes));
842    std::thread::spawn(move || {
843        let reporting = post.clone();
844        let outcome = walker.run(move |found| {
845            // A closed channel means the reader has quit. Nothing to do about it here, and
846            // the walk stops on its own when it finishes.
847            let _ = reporting.send(Message::Found(found));
848        });
849        let _ = post.send(Message::Scanned(outcome));
850    })
851}
852
853/// Starts a removal, reporting each target as it finishes and the whole thing at the end.
854///
855/// The handle is kept by the loop, which will not leave until this thread is done: see
856/// [`View::wants_to_quit`].
857fn spawn_delete(options: &Options, targets: Vec<PathBuf>, post: Sender<Message>) -> JoinHandle<()> {
858    let planner = Planner::new(&options.root)
859        .one_file_system(options.one_file_system)
860        .older_than(options.older_than);
861    std::thread::spawn(move || {
862        // Re-planned rather than the plan the question was asked about, and the reason is
863        // #595's: a plan is a set of `stat`s taken at a moment, and the moment has passed.
864        // The targets are the ones the reader confirmed, so nothing new can enter the batch —
865        // this can only refuse more, never less.
866        let plan = planner.plan(targets.iter().map(Target::at));
867        let reporting = post.clone();
868        let removal = Deleter::new()
869            .watching(move |step| {
870                let _ = reporting.send(Message::Removing(step.clone()));
871            })
872            .remove(&plan);
873        // Posted before the thread ends, which is what lets `reap` read a finished thread
874        // with an empty channel as "this one died without saying anything".
875        let _ = post.send(Message::Deleted(Box::new(removal)));
876    })
877}
878
879/// The **one** thread that prices subtrees a reader has asked about, and the queue into it.
880///
881/// A full [`SizeMode::Breakdown`] whatever the command line asked for, and that is the whole
882/// gesture: `--breakdown-under` scopes what the *scan* pays for, and this is the reader
883/// pointing at one more subtree afterwards and paying for that one too. It stays on one
884/// filesystem when the run does, because what the deleter will not cross the measurer must
885/// not count.
886///
887/// # One worker, not one per gesture
888///
889/// A double click is a gesture a reader can repeat faster than a traversal of a real
890/// `node_modules` finishes, so a thread per press is an unbounded number of concurrent full
891/// walks over the same disk — which is slower than doing them in turn as well as unbounded.
892/// Requests queue here instead, and there is only ever one of these in a run because
893/// [`drive`] holds a single [`Option`] of its sender.
894///
895/// The queue cannot grow without bound either, and that guarantee is the *view's*:
896/// [`View::price_row`] refuses a claim it has already asked about, so what can be waiting
897/// here is at most the unpriced claims in the tree rather than however many times somebody
898/// pressed the button.
899///
900/// Detached and unjoined, for the walk's reason rather than the removal's: it only reads, so
901/// abandoning one at the end of a run costs nothing. It ends on its own when [`drive`] drops
902/// the sender — which is why a run that never prices anything never starts it at all.
903fn spawn_pricer(options: &Options, post: Sender<Message>) -> Sender<Vec<PathBuf>> {
904    let (ask, queue) = channel::<Vec<PathBuf>>();
905    let measurer = Measurer::new(SizeMode::Breakdown).same_file_system(options.one_file_system);
906    std::thread::spawn(move || {
907        while let Ok(claims) = queue.recv() {
908            let mut errors = Vec::new();
909            for path in &claims {
910                let metadata = match std::fs::symlink_metadata(path) {
911                    Ok(metadata) => metadata,
912                    // The directory has gone since the scan claimed it. Not a failure of the
913                    // run — somebody else's `rm` is a fact about the machine — but it is why
914                    // the row is about to keep its dash, so it is said rather than swallowed.
915                    Err(err) => {
916                        errors.push(WalkError {
917                            path: Some(path.clone()),
918                            forbidden: err.kind() == io::ErrorKind::PermissionDenied,
919                            message: err.to_string(),
920                        });
921                        continue;
922                    }
923                };
924                let sized = measurer.measure(path, &metadata);
925                errors.extend(sized.unreadable.into_iter().map(|path| WalkError {
926                    path: Some(path),
927                    message: "unreadable, so this size is a lower bound".to_owned(),
928                    forbidden: false,
929                }));
930                let _ = post.send(Message::Found(Found::Priced(Priced {
931                    path: path.clone(),
932                    size: sized.size,
933                })));
934            }
935            // The claims go back with the report, because the view is holding them as "being
936            // priced" and nothing else knows which ones this pass had.
937            let _ = post.send(Message::Repriced { claims, errors });
938        }
939    });
940    ask
941}
942
943/// One line about what the scan found, for the notification that says it is over.
944///
945/// The header's own numbers rather than a second phrasing of them: somebody who comes back to
946/// a notification and then looks at the screen has to be able to see the same run described.
947fn scanned(view: &View) -> String {
948    let total = view.total();
949    format!(
950        "{} reclaimable in {}",
951        human(total.bytes),
952        plural(total.claims, "directory", "directories")
953    )
954}
955
956/// One line about what a removal did, for the footer.
957///
958/// Failures and refusals are named as counts rather than swallowed: a batch where half the
959/// targets were left standing has to say so, and the rows are still there to be looked at.
960///
961/// Naming one is also what decides how long the sentence lasts. A clean removal is a
962/// [`Notice::passing`] — the reader's next keystroke has seen it off, and the tree it describes
963/// is the tree in front of them. A removal that left something behind is a
964/// [`Notice::standing`]: those counts are what the run exits non-zero on, so this line is the
965/// only place a reader who is not reading the exit status learns of them, and an arrow key
966/// pressed while reading must not be what takes it away.
967fn summarise(removal: &Removal) -> Notice {
968    let mut said = vec![format!(
969        "removed {} from {}",
970        human(removal.bytes_freed()),
971        plural(removal.removed.len(), "directory", "directories")
972    )];
973    if !removal.kept.is_empty() {
974        said.push(format!(
975            "{} left alone",
976            plural(removal.kept.len(), "directory", "directories")
977        ));
978    }
979    if !removal.failures.is_empty() {
980        said.push(format!(
981            "{} failed",
982            plural(removal.failures.len(), "directory", "directories")
983        ));
984    }
985    let said = said.join(", ");
986    if removal.kept.is_empty() && removal.failures.is_empty() {
987        Notice::passing(said)
988    } else {
989        Notice::standing(said)
990    }
991}
992
993/// The size mode a live view runs under.
994///
995/// A scoped breakdown is honoured as given — that flag is a request to price one subtree and
996/// nothing else. Everything else becomes a full breakdown, including the `Skip` that is the
997/// listing's default: see the module docs for why a tree of dashes is not a front end.
998#[must_use]
999pub fn size_mode(asked: SizeMode) -> SizeMode {
1000    match asked {
1001        SizeMode::BreakdownUnder(scope) => SizeMode::BreakdownUnder(scope),
1002        SizeMode::Skip | SizeMode::Breakdown => SizeMode::Breakdown,
1003    }
1004}
1005
1006#[cfg(test)]
1007mod tests {
1008    use super::{
1009        Batch, Chrome, Decor, Drawn, Maps, Message, Options, Outcome, Placed, Restore, Screen,
1010        SizeMode, drain, map, reap, spawn_pricer, summarise,
1011    };
1012    use crate::delete::{Failure, Refusal, Refused, Removal, Removed};
1013    use crate::fixture::priced;
1014    use crate::tree::Tree;
1015    use crate::tui::chrome::XTERM_STACK;
1016    use crate::tui::state::View;
1017    use crate::walk::{Found, WalkError, WalkOutcome};
1018    use ratatui::layout::Rect;
1019    use std::path::Path;
1020    use std::sync::mpsc::channel;
1021
1022    fn view() -> View {
1023        View::new(Tree::new("/scan"))
1024    }
1025
1026    #[test]
1027    fn a_pane_with_no_cell_size_to_draw_in_answers_with_the_reason_rather_than_with_nothing() {
1028        // The seam `drive`'s reconciliation guards, driven directly. It cannot be reached
1029        // through the loop while the gate and the pane are both taken from one reading of the
1030        // window — which is the whole of the fix — so this is what keeps the backstop honest:
1031        // if a later change ever hands `map` a pane the cell size cannot fill, the answer
1032        // names which gate refused instead of being the `Ok(())` that made #656 silent.
1033        let mut tree = Tree::new("/scan");
1034        tree.insert(priced("/scan/nx/node_modules", 8 * 1024 * 1024));
1035        let mut view = View::new(tree);
1036        view.allow_maps(Maps::Can);
1037        view.sync();
1038        let mut screen = Screen::new(Vec::new(), true);
1039        let placed = Placed {
1040            map: Some(Rect::new(60, 1, 40, 20)),
1041            ..Placed::default()
1042        };
1043
1044        assert_eq!(
1045            map(&mut screen, &view, &placed, None).unwrap(),
1046            Drawn::Cannot(Maps::Unmeasured)
1047        );
1048        assert!(screen.sink().is_empty(), "an image was sized from a guess");
1049
1050        // …and the same pane, once the terminal will say how big a cell is, is a picture.
1051        assert_eq!(
1052            map(&mut screen, &view, &placed, Some((9, 19))).unwrap(),
1053            Drawn::Map
1054        );
1055        assert!(!screen.sink().is_empty());
1056    }
1057
1058    fn removed(path: &str) -> Removed {
1059        Removed {
1060            path: path.into(),
1061            bytes: 1024,
1062            entries: 3,
1063            complete: true,
1064        }
1065    }
1066
1067    #[test]
1068    fn a_scan_that_could_not_read_a_path_is_not_a_whole_run() {
1069        let (post, inbox) = channel();
1070        let mut view = view();
1071        let mut outcome = Outcome::default();
1072        assert!(outcome.whole());
1073
1074        post.send(Message::Found(Found::Claim(priced("/scan/a/target", 8))))
1075            .unwrap();
1076        post.send(Message::Scanned(WalkOutcome {
1077            errors: vec![WalkError {
1078                path: Some("/scan/locked".into()),
1079                message: "Permission denied".to_owned(),
1080                forbidden: true,
1081            }],
1082            ..WalkOutcome::default()
1083        }))
1084        .unwrap();
1085        drain(&mut view, &inbox, &mut outcome);
1086
1087        // The header says so on screen, and this is the same fact reaching a script. Tested
1088        // through the real drain rather than through a terminal, because the terminal is the
1089        // one part of this that a test cannot have.
1090        assert_eq!(outcome.errors.len(), 1);
1091        assert!(!outcome.whole());
1092        assert!(!view.is_scanning());
1093    }
1094
1095    #[test]
1096    fn a_removals_progress_reaches_the_rows_and_the_freed_counter_from_one_event() {
1097        use crate::delete::{Freeing, Step};
1098        use crate::size::Size;
1099        use std::time::Instant;
1100
1101        let (post, inbox) = channel();
1102        let mut tree = Tree::new("/scan");
1103        tree.insert(crate::fixture::hit(
1104            "/scan/app/node_modules",
1105            Size::Measured(1000),
1106            0,
1107        ));
1108        let mut view = View::new(tree);
1109        view.viewport(20);
1110        let mut outcome = Outcome::default();
1111        let start = Instant::now();
1112        view.animate(start);
1113        assert_eq!(view.drawn_total().bytes, 1000);
1114        assert!(!view.has_freed());
1115
1116        // What the deleter says while it is working. The wiring is the point of this test:
1117        // the same event has to reach the row's number and the freed counter, because the
1118        // two moving in opposite directions is only true if they are the same bytes.
1119        post.send(Message::Removing(Step::Freeing(Freeing {
1120            path: "/scan/app/node_modules".into(),
1121            bytes: 600,
1122            entries: 12,
1123        })))
1124        .unwrap();
1125        drain(&mut view, &inbox, &mut outcome);
1126        view.animate(start);
1127
1128        assert_eq!(view.drawn_total().bytes, 400);
1129        assert_eq!(view.drawn_freed(), 600);
1130        // Still on disk, still on screen, and not yet dimmed — it is emptying, not emptied.
1131        let row = view
1132            .tree()
1133            .find(Path::new("/scan/app/node_modules"))
1134            .unwrap();
1135        assert!(view.is_freeing(row));
1136        assert!(!view.is_spent(row));
1137
1138        post.send(Message::Removing(Step::Finished(Removed {
1139            path: "/scan/app/node_modules".into(),
1140            bytes: 1000,
1141            entries: 20,
1142            complete: true,
1143        })))
1144        .unwrap();
1145        drain(&mut view, &inbox, &mut outcome);
1146        view.animate(start);
1147
1148        assert_eq!(view.drawn_total().bytes, 0);
1149        assert_eq!(view.drawn_freed(), 1000);
1150        assert!(view.is_spent(row));
1151    }
1152
1153    #[test]
1154    fn a_real_deleter_reports_paths_the_view_can_find_its_rows_by() {
1155        use crate::delete::{Deleter, Planner, Target};
1156        use crate::size::Size;
1157        use crate::tui::keymap::Turn;
1158        use crate::tui::state::Effect;
1159        use std::time::Instant;
1160
1161        // Every other test in this module posts messages it wrote itself, so the paths match
1162        // the tree by construction and the halves can never be caught disagreeing. This one
1163        // lets the **deleter** choose them.
1164        //
1165        // That is the whole bug this pins. The tree is keyed on the spelling the walk produced
1166        // — `.`-relative for a bare `pristine` — and the planner resolves every target before
1167        // touching it, so the two spell one directory two ways. Report the resolved one and
1168        // every `tree.find` in the view misses. Nothing errors: rows never empty, no row ever
1169        // leaves, the headline reclaimable total never falls, and the only thing that moves is
1170        // the position, which needs no path. A reader watching 150 GiB be deleted sees a
1171        // completely still screen and a percentage climbing to 100.
1172        let tmp = tempfile::TempDir::new().unwrap();
1173        let base = std::fs::canonicalize(tmp.path()).unwrap();
1174        std::fs::create_dir_all(base.join("real")).unwrap();
1175        // A name for the root that is not its canonical one, which is what `.` is to a bare
1176        // run. A symlink because it is deterministic; the relative case is the common one.
1177        let root = base.join("link");
1178        std::os::unix::fs::symlink(base.join("real"), &root).unwrap();
1179        let target = root.join("app/node_modules");
1180        std::fs::create_dir_all(target.join("dep")).unwrap();
1181        std::fs::write(target.join("dep/index.js"), vec![b'x'; 8192]).unwrap();
1182
1183        let mut tree = Tree::new(&root);
1184        tree.insert(crate::fixture::hit(
1185            target.to_str().unwrap(),
1186            Size::Measured(8192),
1187            0,
1188        ));
1189        let mut view = View::new(tree);
1190        view.viewport(20);
1191        let start = Instant::now();
1192        view.animate(start);
1193        assert_eq!(view.drawn_total().bytes, 8192);
1194
1195        // Through the real confirmation, and the deed is whatever **it** hands back. Building
1196        // the plan from a path of the test's own choosing would step over the half of this that
1197        // lives in the dialog: the batch it produces is what decides which spelling the deleter
1198        // is given, and therefore which one comes back.
1199        view.ask(&Planner::new(&root).plan([Target::at(&target)]));
1200        view.apply(Action::Highlight(Turn::Next));
1201        let deed = view.apply(Action::Answer);
1202        assert_eq!(view.removing().unwrap().weighed(), Some((0, 8192)));
1203        let Effect::Delete(deed) = deed else {
1204            panic!("the confirmation did not produce a removal: {deed:?}");
1205        };
1206
1207        let (post, inbox) = channel();
1208        let plan = Planner::new(&root).plan(deed.iter().map(Target::at));
1209        let reporting = post.clone();
1210        let removal = Deleter::new()
1211            .watching(move |step| {
1212                let _ = reporting.send(Message::Removing(step.clone()));
1213            })
1214            .remove(&plan);
1215        assert!(removal.is_clean(), "{:?}", removal.failures);
1216        let mut outcome = Outcome::default();
1217        drain(&mut view, &inbox, &mut outcome);
1218        view.animate(start);
1219
1220        // The row emptied and left, which is only true if the deleter's paths found it.
1221        let row = view
1222            .tree()
1223            .find(&target)
1224            .expect("the row is still in the tree");
1225        assert!(
1226            view.is_spent(row),
1227            "the row never emptied, so the report never found it"
1228        );
1229        assert_eq!(view.drawn_total().bytes, 0);
1230        // …and the footer's byte figure moved with it, rather than sitting at zero of the
1231        // batch's weight for the whole run.
1232        let (freed, planned) = view.removing().unwrap().weighed().unwrap();
1233        assert_eq!(planned, 8192);
1234        assert!(freed > 0, "the batch freed {freed} of {planned}");
1235    }
1236
1237    #[test]
1238    fn the_batchs_position_advances_on_a_target_the_deleter_could_not_touch() {
1239        use crate::delete::Step;
1240
1241        let (post, inbox) = channel();
1242        let mut view = view();
1243        let mut outcome = Outcome::default();
1244        // One target, and the deleter fails on it before unlinking anything — so there is no
1245        // `Finished` and no row to move, only the pool saying it has moved on.
1246        view.deleting_for_test();
1247        assert_eq!(view.removing().unwrap().counted(), (0, 1));
1248
1249        post.send(Message::Removing(Step::Swept("/scan/a/target".into())))
1250            .unwrap();
1251        drain(&mut view, &inbox, &mut outcome);
1252
1253        // The wiring is the point: a missing arm here would leave the bar at zero for the
1254        // whole run and say nothing about it.
1255        assert_eq!(view.removing().unwrap().counted(), (1, 1));
1256        assert_eq!(view.removing().unwrap().percent(), 100);
1257    }
1258
1259    #[test]
1260    fn a_removal_that_failed_is_not_a_whole_run_and_a_removal_that_was_refused_is() {
1261        let (post, inbox) = channel();
1262        let mut view = view();
1263        let mut outcome = Outcome::default();
1264
1265        // A refusal is the safety model working, which is exactly the distinction
1266        // `Removal::is_clean` draws — and the listing exits zero on one.
1267        post.send(Message::Deleted(Box::new(Removal {
1268            removed: vec![removed("/scan/a/target")],
1269            kept: vec![Refused {
1270                path: "/scan/b/node_modules".into(),
1271                reason: Refusal::HoldsCheckout,
1272            }],
1273            failures: Vec::new(),
1274        })))
1275        .unwrap();
1276        drain(&mut view, &inbox, &mut outcome);
1277        assert!(outcome.whole());
1278        assert!(view.notice().unwrap().contains("left alone"));
1279
1280        post.send(Message::Deleted(Box::new(Removal {
1281            removed: Vec::new(),
1282            kept: Vec::new(),
1283            failures: vec![Failure {
1284                path: "/scan/c/target".into(),
1285                message: "Device or resource busy".to_owned(),
1286            }],
1287        })))
1288        .unwrap();
1289        drain(&mut view, &inbox, &mut outcome);
1290        assert_eq!(outcome.failures, 1);
1291        assert!(!outcome.whole());
1292    }
1293
1294    #[test]
1295    fn a_removal_that_ended_without_reporting_does_not_leave_the_view_unable_to_quit() {
1296        let (post, inbox) = channel();
1297        let mut view = view();
1298        let mut outcome = Outcome::default();
1299        // A thread that has already ended, having said nothing — which is what a panic on
1300        // the pool would look like from here.
1301        let dead = std::thread::spawn(|| {});
1302        while !dead.is_finished() {
1303            std::thread::yield_now();
1304        }
1305        view.deleting_for_test();
1306
1307        reap(&mut view, &inbox, &mut outcome, Some(&dead));
1308
1309        assert!(!view.is_deleting(), "the view would never quit again");
1310        assert_eq!(outcome.failures, 1);
1311        drop(post);
1312    }
1313
1314    #[test]
1315    fn a_removal_that_reported_on_its_way_out_is_read_rather_than_called_a_failure() {
1316        let (post, inbox) = channel();
1317        let mut view = view();
1318        let mut outcome = Outcome::default();
1319        let dead = std::thread::spawn(|| {});
1320        while !dead.is_finished() {
1321            std::thread::yield_now();
1322        }
1323        view.deleting_for_test();
1324        // The report is posted before the thread ends, so a finished thread can still have
1325        // something on the channel. Calling that a failure would fail every clean removal.
1326        post.send(Message::Deleted(Box::default())).unwrap();
1327
1328        reap(&mut view, &inbox, &mut outcome, Some(&dead));
1329
1330        assert!(!view.is_deleting());
1331        assert_eq!(outcome.failures, 0);
1332        assert!(outcome.whole());
1333    }
1334
1335    #[test]
1336    fn a_batch_waits_for_its_removal_however_the_scope_ends() {
1337        use std::sync::Arc;
1338        use std::sync::atomic::{AtomicBool, Ordering};
1339
1340        let finished = Arc::new(AtomicBool::new(false));
1341        let worker = Arc::clone(&finished);
1342        {
1343            let mut batch = Batch::default();
1344            batch.takes_over(std::thread::spawn(move || {
1345                std::thread::sleep(std::time::Duration::from_millis(50));
1346                worker.store(true, Ordering::SeqCst);
1347            }));
1348            // …and here the scope ends, which is what a `?`, a `break` and a panic all do.
1349        }
1350        assert!(
1351            finished.load(Ordering::SeqCst),
1352            "the removal was abandoned rather than waited for"
1353        );
1354    }
1355
1356    #[test]
1357    fn one_pricing_worker_answers_a_queue_of_requests_and_ends_when_the_loop_lets_go() {
1358        // The other half of the bound. The view refuses to ask twice for a claim it is
1359        // already waiting on; this is what makes the requests that *do* get through cost one
1360        // traversal at a time rather than one thread each.
1361        let tmp = tempfile::TempDir::new().unwrap();
1362        let first = tmp.path().join("a/node_modules");
1363        let second = tmp.path().join("b/node_modules");
1364        for dir in [&first, &second] {
1365            std::fs::create_dir_all(dir).unwrap();
1366            std::fs::write(dir.join("f.js"), "xxxx").unwrap();
1367        }
1368
1369        let (post, inbox) = channel();
1370        let queue = spawn_pricer(
1371            &Options {
1372                root: tmp.path().to_path_buf(),
1373                min_size: crate::DEFAULT_MIN_SIZE,
1374                size_mode: SizeMode::Skip,
1375                one_file_system: true,
1376                older_than: None,
1377                ignored_files: false,
1378                excludes: std::sync::Arc::new(ignore::gitignore::Gitignore::empty()),
1379            },
1380            post,
1381        );
1382        queue.send(vec![first.clone()]).unwrap();
1383        queue.send(vec![second.clone()]).unwrap();
1384
1385        // Both requests answered, in the order they were queued, by the one worker.
1386        let mut reported = Vec::new();
1387        let mut sized = 0;
1388        while reported.len() < 2 {
1389            match inbox.recv().unwrap() {
1390                Message::Repriced { claims, errors } => {
1391                    assert!(errors.is_empty(), "{errors:?}");
1392                    reported.push(claims);
1393                }
1394                // Each claim's price goes out on its own first, exactly as the walk's do,
1395                // so a row fills in without waiting for the rest of its batch.
1396                Message::Found(Found::Priced(priced)) => {
1397                    assert!(priced.size.bytes().is_some_and(|bytes| bytes > 0));
1398                    sized += 1;
1399                }
1400                _ => panic!("the pricer said something else"),
1401            }
1402        }
1403        assert_eq!(reported, [vec![first], vec![second]]);
1404        assert_eq!(sized, 2);
1405
1406        // The claims come back with the report, which is what lets the view stop holding
1407        // them — and the worker ends when the loop drops the queue rather than parking for
1408        // the life of the process. Nothing else holds a sender, so the channel disconnects.
1409        drop(queue);
1410        assert!(matches!(inbox.recv(), Err(std::sync::mpsc::RecvError)));
1411    }
1412
1413    #[test]
1414    fn a_summary_names_what_was_left_behind_as_well_as_what_went() {
1415        let removal = Removal {
1416            removed: vec![removed("/scan/a/target")],
1417            kept: vec![Refused {
1418                path: "/scan/b".into(),
1419                reason: Refusal::HoldsCheckout,
1420            }],
1421            failures: vec![Failure {
1422                path: "/scan/c".into(),
1423                message: "busy".to_owned(),
1424            }],
1425        };
1426        let notice = summarise(&removal);
1427        let said = notice.said();
1428        assert!(said.contains("removed 1.0 KiB from 1 directory"), "{said}");
1429        assert!(said.contains("1 directory left alone"), "{said}");
1430        assert!(said.contains("1 directory failed"), "{said}");
1431        // Naming either of them is what makes the sentence wait to be dismissed: these counts
1432        // are what the run exits non-zero on, so an arrow key must not be what clears them.
1433        assert!(notice.stands(), "{said}");
1434    }
1435
1436    #[test]
1437    fn a_removal_that_left_nothing_behind_does_not_have_to_be_dismissed() {
1438        let notice = summarise(&Removal {
1439            removed: vec![removed("/scan/a/target")],
1440            ..Removal::default()
1441        });
1442        assert_eq!(notice.said(), "removed 1.0 KiB from 1 directory");
1443        // Nothing was refused and nothing failed, so there is nothing here a reader has to be
1444        // given the chance to have seen: the next thing they do takes it away.
1445        assert!(!notice.stands());
1446    }
1447
1448    // ---- the pointer's gestures -------------------------------------------------------
1449    //
1450    // The three facts the loop holds that a mouse event cannot see: whether a press has
1451    // already moved, what it landed on, and whether one landed on this same thing a moment
1452    // ago. Asserted here rather than through a terminal, because a test cannot press a
1453    // button on a real one — and because `Pointer` is where the whole gesture lives.
1454
1455    use super::{Action, Motion, Pointer};
1456    use crate::tree::Order;
1457    use crate::tui::render::{Spot, Zone};
1458    use ratatui::crossterm::event::{MouseButton, MouseEventKind};
1459    use std::time::{Duration, Instant};
1460
1461    fn down() -> MouseEventKind {
1462        MouseEventKind::Down(MouseButton::Left)
1463    }
1464
1465    fn up() -> MouseEventKind {
1466        MouseEventKind::Up(MouseButton::Left)
1467    }
1468
1469    fn row(id: crate::tree::NodeId) -> Spot {
1470        Spot::Row {
1471            id,
1472            zone: Zone::Name,
1473        }
1474    }
1475
1476    #[test]
1477    fn a_press_aims_and_the_click_happens_when_it_is_let_go() {
1478        let mut pointer = Pointer::default();
1479        let now = Instant::now();
1480        let heading = Spot::Heading(Order::Age);
1481
1482        // The rule the deferred click exists for. A press that acted would have re-sorted
1483        // the tree by the time a drag could decline to act again.
1484        assert_eq!(pointer.read(down(), heading, now), Action::Ignore);
1485        assert_eq!(pointer.read(up(), heading, now), Action::SortBy(Order::Age));
1486        // …and a release with nothing behind it — a button that went down before pristine
1487        // was reporting — is not a click either.
1488        assert_eq!(pointer.read(up(), heading, now), Action::Ignore);
1489    }
1490
1491    #[test]
1492    fn a_press_that_moved_is_a_drag_and_never_becomes_a_click() {
1493        let mut pointer = Pointer::default();
1494        let now = Instant::now();
1495        let heading = Spot::Heading(Order::Size);
1496
1497        pointer.read(down(), heading, now);
1498        assert_eq!(
1499            pointer.read(MouseEventKind::Drag(MouseButton::Left), Spot::Tree, now),
1500            Action::Ignore
1501        );
1502        // Sticky: a hand that wandered a cell and came back has still dragged, so the
1503        // release finishes nothing however close to the press it lands.
1504        assert_eq!(pointer.read(up(), heading, now), Action::Ignore);
1505    }
1506
1507    #[test]
1508    fn a_drag_cannot_be_half_of_a_double_click() {
1509        let mut pointer = Pointer::default();
1510        let now = Instant::now();
1511
1512        // Drag a selection out of a row, then press where the drag started. Without the
1513        // rule, that second press completes a double and prices a subtree the reader was
1514        // aiming to select from.
1515        pointer.read(down(), row(4), now);
1516        pointer.read(MouseEventKind::Drag(MouseButton::Left), row(4), now);
1517        pointer.read(up(), row(4), now);
1518
1519        pointer.read(down(), row(4), now + Duration::from_millis(50));
1520        assert_eq!(
1521            pointer.read(up(), row(4), now + Duration::from_millis(50)),
1522            Action::Select(4)
1523        );
1524    }
1525
1526    #[test]
1527    fn a_completed_double_click_starts_over_rather_than_arming_the_next_press() {
1528        let mut pointer = Pointer::default();
1529        let mut now = Instant::now();
1530        let click = |pointer: &mut Pointer, now: Instant| {
1531            pointer.read(down(), row(9), now);
1532            pointer.read(up(), row(9), now)
1533        };
1534
1535        assert_eq!(click(&mut pointer, now), Action::Select(9));
1536        now += Duration::from_millis(50);
1537        assert_eq!(click(&mut pointer, now), Action::Price(9));
1538        // The third press of a triple is a first press again. Without this, leaning on the
1539        // button prices the row over and over and a triple click is two doubles.
1540        now += Duration::from_millis(50);
1541        assert_eq!(click(&mut pointer, now), Action::Select(9));
1542    }
1543
1544    #[test]
1545    fn two_presses_far_enough_apart_are_two_clicks() {
1546        let mut pointer = Pointer::default();
1547        let now = Instant::now();
1548        pointer.read(down(), row(2), now);
1549        pointer.read(up(), row(2), now);
1550
1551        let late = now + super::DOUBLE_CLICK + Duration::from_millis(1);
1552        pointer.read(down(), row(2), late);
1553        assert_eq!(pointer.read(up(), row(2), late), Action::Select(2));
1554    }
1555
1556    #[test]
1557    fn a_row_that_moved_under_a_steady_finger_is_a_first_press_and_not_a_double() {
1558        let mut pointer = Pointer::default();
1559        let now = Instant::now();
1560
1561        // The same cell, 50 ms apart, holding a different directory — which is what a price
1562        // landing between two presses does to a level sorted by size. Judged on coordinates
1563        // this would price the stranger that fell into the position; judged on the spot it
1564        // selects, which is what a first press means.
1565        pointer.read(down(), row(4), now);
1566        pointer.read(up(), row(4), now);
1567        pointer.read(down(), row(11), now + Duration::from_millis(50));
1568        assert_eq!(
1569            pointer.read(up(), row(11), now + Duration::from_millis(50)),
1570            Action::Select(11)
1571        );
1572    }
1573
1574    #[test]
1575    fn the_wheel_needs_no_press_behind_it() {
1576        let mut pointer = Pointer::default();
1577        assert_eq!(
1578            pointer.read(MouseEventKind::ScrollDown, Spot::Tree, Instant::now()),
1579            Action::ScrollRows(Motion::Down)
1580        );
1581        assert_eq!(
1582            pointer.read(MouseEventKind::ScrollUp, Spot::Tree, Instant::now()),
1583            Action::ScrollRows(Motion::Up)
1584        );
1585    }
1586
1587    #[test]
1588    fn the_buttons_pristine_does_not_use_are_left_to_the_terminals_own_menus() {
1589        let mut pointer = Pointer::default();
1590        let now = Instant::now();
1591        for kind in [
1592            MouseEventKind::Down(MouseButton::Right),
1593            MouseEventKind::Up(MouseButton::Right),
1594            MouseEventKind::Down(MouseButton::Middle),
1595        ] {
1596            assert_eq!(pointer.read(kind, row(1), now), Action::Ignore, "{kind:?}");
1597        }
1598        // …and a right-button press does not arm a left-button release either.
1599        pointer.read(MouseEventKind::Down(MouseButton::Right), row(1), now);
1600        assert_eq!(pointer.read(up(), row(1), now), Action::Ignore);
1601    }
1602
1603    #[test]
1604    fn restoring_the_terminal_attempts_every_step_that_was_reached() {
1605        // The states are global to the process, so what is asserted here is the bookkeeping:
1606        // `finish` undoes exactly what was entered, and having run it, `Drop` has nothing
1607        // left to do. That is the whole of what the early-failure path depends on — a flag
1608        // still set is a step Drop would take a second time, and a flag never set is a step
1609        // neither of them would take at all.
1610        //
1611        // It really does call `disable_raw_mode` and leave the alternate screen, on a process
1612        // that is in neither state. Both are no-ops there, and the escape sequence goes to
1613        // the harness's captured stdout.
1614        let mut restore = Restore::new(
1615            Chrome::new(Vec::new(), Decor::silent()),
1616            Screen::new(Vec::new(), false),
1617        );
1618        assert!(restore.finish().is_ok(), "nothing was taken");
1619
1620        restore.raw = true;
1621        restore.alternate = true;
1622        restore.mouse = true;
1623        assert!(restore.finish().is_ok());
1624        assert!(!restore.raw, "raw mode would be disabled twice");
1625        assert!(
1626            !restore.alternate,
1627            "the alternate screen would be left twice"
1628        );
1629        // The sharpest case of the rule, because its failure is the one nothing in the
1630        // process is still alive to notice: a shell left reporting the mouse answers every
1631        // movement of the hand with escape gibberish.
1632        assert!(!restore.mouse, "the mouse would be released twice");
1633    }
1634
1635    #[test]
1636    fn the_decorations_are_handed_back_by_the_same_guard_and_not_a_second_one() {
1637        // The reason the chrome is a field of `Restore` rather than a guard beside it. What
1638        // is asserted is that the ordinary way out and the drop path are one path: `finish`
1639        // does the whole undoing, and `Drop` afterwards has nothing left to write.
1640        let mut restore = Restore::new(
1641            Chrome::new(
1642                Vec::new(),
1643                Decor {
1644                    sync: true,
1645                    title: Some(XTERM_STACK),
1646                    progress: true,
1647                    notify: None,
1648                    graphics: false,
1649                },
1650            ),
1651            Screen::new(Vec::new(), false),
1652        );
1653        restore.chrome.enter().unwrap();
1654        restore.chrome.begin_frame().unwrap();
1655        restore.finish().unwrap();
1656
1657        let said = String::from_utf8(restore.chrome.sink().clone()).unwrap();
1658        assert!(said.contains("\x1b[?2026l"), "a frozen screen: {said:?}");
1659        assert!(said.contains("\x1b]9;4;0;0\x07"), "the bar was left up");
1660        assert!(said.ends_with("\x1b[23;2t"), "the title was not put back");
1661
1662        // And this is exactly what `Drop` runs a moment later, on every path that returns
1663        // through a `?`. Undoing it twice would pop a title this run never pushed.
1664        restore.finish().unwrap();
1665        assert_eq!(
1666            restore.chrome.sink().len(),
1667            said.len(),
1668            "the undoing was written a second time"
1669        );
1670    }
1671}
1672
1673/// Driving the event loop itself, which needs a terminal that can be made to fail and a
1674/// keyboard that can be made to type.
1675///
1676/// The findings this exists for were all in the loop, and all of the same shape: an exit the
1677/// happy path does not take. There is no way to reach one from outside without these two
1678/// seams, which is exactly why they are here.
1679#[cfg(test)]
1680mod loop_tests {
1681    use super::{Chrome, Decor, Events, Options, Screen, drive};
1682    use crate::Ruleset;
1683    use crate::size::SizeMode;
1684    use crate::tui::chrome::XTERM_STACK;
1685    use ratatui::Terminal;
1686    use ratatui::backend::{Backend, TestBackend, WindowSize};
1687    use ratatui::buffer::Cell;
1688    use ratatui::crossterm::event::{
1689        Event, KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind,
1690    };
1691    use ratatui::layout::{Position, Size};
1692    use std::io;
1693    use std::path::{Path, PathBuf};
1694    use std::sync::Arc;
1695    use std::sync::atomic::{AtomicBool, Ordering};
1696    use std::time::{Duration, Instant};
1697    use tempfile::TempDir;
1698
1699    /// A result from a backend that cannot fail, in the shape of one that can.
1700    fn never<T>(result: Result<T, std::convert::Infallible>) -> io::Result<T> {
1701        result.map_err(|never| match never {})
1702    }
1703
1704    /// A terminal that stops working the moment something else says so.
1705    ///
1706    /// Every method delegates; only `draw` reads the flag. That is the narrowest injection
1707    /// that reaches the `?` under test, and it is a real `io::Error` on the real code path
1708    /// rather than a branch added for the test.
1709    struct Flaky {
1710        inner: TestBackend,
1711        broken: Arc<AtomicBool>,
1712        /// A terminal that answers `TIOCGWINSZ` with zeros in the pixel fields.
1713        ///
1714        /// Which is not an exotic terminal: tmux does not forward them, so this is what every
1715        /// run inside one sees, however capable the terminal outside it is. It is the whole
1716        /// of #656 — the allowlist says yes and the window size says nothing.
1717        blind: bool,
1718    }
1719
1720    impl Backend for Flaky {
1721        type Error = io::Error;
1722
1723        fn draw<'a, I>(&mut self, content: I) -> io::Result<()>
1724        where
1725            I: Iterator<Item = (u16, u16, &'a Cell)>,
1726        {
1727            if self.broken.load(Ordering::SeqCst) {
1728                return Err(io::Error::other("the terminal went away"));
1729            }
1730            never(self.inner.draw(content))
1731        }
1732
1733        fn hide_cursor(&mut self) -> io::Result<()> {
1734            never(self.inner.hide_cursor())
1735        }
1736
1737        fn show_cursor(&mut self) -> io::Result<()> {
1738            never(self.inner.show_cursor())
1739        }
1740
1741        fn get_cursor_position(&mut self) -> io::Result<Position> {
1742            never(self.inner.get_cursor_position())
1743        }
1744
1745        fn set_cursor_position<P: Into<Position>>(&mut self, position: P) -> io::Result<()> {
1746            never(self.inner.set_cursor_position(position))
1747        }
1748
1749        fn clear(&mut self) -> io::Result<()> {
1750            never(self.inner.clear())
1751        }
1752
1753        fn clear_region(&mut self, clear_type: ratatui::backend::ClearType) -> io::Result<()> {
1754            never(self.inner.clear_region(clear_type))
1755        }
1756
1757        fn size(&self) -> io::Result<Size> {
1758            never(self.inner.size())
1759        }
1760
1761        fn window_size(&mut self) -> io::Result<WindowSize> {
1762            let window = never(self.inner.window_size())?;
1763            Ok(WindowSize {
1764                pixels: if self.blind {
1765                    Size::new(0, 0)
1766                } else {
1767                    window.pixels
1768                },
1769                ..window
1770            })
1771        }
1772
1773        fn flush(&mut self) -> io::Result<()> {
1774            never(self.inner.flush())
1775        }
1776    }
1777
1778    /// How long a script will keep performing before it gives up and quits anyway.
1779    ///
1780    /// Only reached by a run that has not satisfied its `until` — which may be a fault in what
1781    /// is being tested and may be a machine too busy to have got there yet, and nothing in here
1782    /// can tell those apart. So the ceiling ends the run and [`Script::why`] reports it without
1783    /// claiming which. The point of having one at all is that such a run *ends*, and says what
1784    /// it found rather than hanging with no output.
1785    ///
1786    /// **In seconds, and deliberately not in events.** An event count measures how fast this
1787    /// loop spins, which is the one quantity here that moves the *wrong* way under load: the
1788    /// script's `poll` never blocks, so a busy machine makes the loop burn its allowance
1789    /// faster at exactly the moment the walker it is waiting on is most starved. Measured on
1790    /// an idle machine a run performs about 400 events; measured under twenty-two concurrent
1791    /// copies of this binary, 14,533 — past the 10,000 this ceiling used to be, so it was
1792    /// firing on real runs and being read as a fault in whatever the next assertion tested.
1793    /// Wall-clock time is the honest expression of "this run has had a fair chance", because
1794    /// it does not depend on how many frames got drawn while nothing happened.
1795    ///
1796    /// Two orders of magnitude above the half-second a healthy run takes, because the only job
1797    /// left to it is ending a genuinely stuck run before the harness's own timeout does.
1798    const PATIENCE: Duration = Duration::from_secs(60);
1799
1800    /// A reader who performs the same short phrase over and over.
1801    ///
1802    /// Repeated rather than sequenced because the loop and the walk are concurrent: the row
1803    /// to mark does not exist until the walker finds it, and a script that fired once would
1804    /// be racing that. Every event in a cycle is harmless before it is useful — `x` with an
1805    /// empty batch says "nothing is marked", and `→`/`Enter` on a childless row do nothing.
1806    struct Script {
1807        events: Vec<Event>,
1808        at: usize,
1809        /// Presses `q` instead of the next key, once this says the run has done its job.
1810        ///
1811        /// A `q` *inside* the cycle would undo the repetition the cycle exists for: the first
1812        /// pass would end the run whether or not the walker had published anything yet, so on
1813        /// a machine under load the `x` lands on an empty batch, nothing is removed, and the
1814        /// assertion fires on scheduling rather than on a fault. Measured, not guessed — the
1815        /// test failed that way under three spinning cores, on this commit and on the one
1816        /// before any of this work.
1817        ///
1818        /// So the quit is a condition, which is the discipline the sibling test already
1819        /// applies to breaking the terminal: wait for the thing to have actually happened.
1820        until: Option<Box<dyn Fn() -> bool + Send>>,
1821        /// How long to keep going before quitting anyway. [`PATIENCE`] unless a test is about
1822        /// the ceiling itself.
1823        patience: Duration,
1824        /// When the first event was read, which is the closest this can get to when [`drive`]
1825        /// started — the script is not built at the same moment it is handed over.
1826        started: Option<Instant>,
1827        /// How long the run had been going when the ceiling fired, if it did.
1828        ///
1829        /// Recorded rather than merely acted on, because acting on it is invisible: see
1830        /// [`Script::why`], which is the only thing that reads it.
1831        ///
1832        /// The duration rather than a flag, because the sentence that reports this has to name
1833        /// the ceiling **this** script waited out. Interpolating [`PATIENCE`] would print 60 s
1834        /// for a test that set its own, which is the "never invent a number" rule broken in
1835        /// the one place a reader has nothing else to go on.
1836        expired: Option<Duration>,
1837    }
1838
1839    impl Script {
1840        /// A reader performing `events` over and over, patient for [`PATIENCE`].
1841        fn new(events: Vec<Event>) -> Self {
1842            Self {
1843                events,
1844                at: 0,
1845                until: None,
1846                patience: PATIENCE,
1847                started: None,
1848                expired: None,
1849            }
1850        }
1851
1852        /// Presses `q` once `finished` says the run has done its job.
1853        fn until(mut self, finished: impl Fn() -> bool + Send + 'static) -> Self {
1854            self.until = Some(Box::new(finished));
1855            self
1856        }
1857
1858        /// Gives up sooner than [`PATIENCE`], for the one test that is about giving up.
1859        fn patience(mut self, patience: Duration) -> Self {
1860            self.patience = patience;
1861            self
1862        }
1863
1864        /// How long this script waited before giving up, or `None` if it never did — which is
1865        /// to say, if the run ended because it got what it was waiting for.
1866        fn gave_up(&self) -> Option<Duration> {
1867            self.expired
1868        }
1869
1870        /// What a failing assertion says: `otherwise`, unless this script is what ended the run.
1871        ///
1872        /// **A timeout is not a verdict, and saying which it was is not this method's to do.**
1873        /// A script that gives up presses `q`, and `q` is indistinguishable from a reader
1874        /// leaving — so an assertion downstream reads a run that never got to the end as a run
1875        /// that got there and got it wrong, and states its own subject as the cause. That is
1876        /// how "the pointer marked nothing" came to be printed for a run that timed out, and
1877        /// it sent a reader to the hit-testing.
1878        ///
1879        /// The correction is to report and **not** to classify. A pointer that has stopped
1880        /// hit-testing, a planner refusing every target and a deleter that never starts all
1881        /// leave the condition false until the ceiling fires — which is the same observable
1882        /// state as a machine too busy to run the walk. Nothing here can tell those apart, so
1883        /// naming either one would be the same invented fact in the opposite direction:
1884        /// exonerating the code under test on evidence that never mentioned it, in the queue
1885        /// gate, where a hidden regression costs the most.
1886        ///
1887        /// So this says how long it waited, what is still on disk, and which assertion was
1888        /// waiting — and stops. The reader gets the two facts and draws their own conclusion.
1889        ///
1890        /// A verdict rather than an assertion of its own, which is a separate point and still
1891        /// holds: giving up is **not** a failure. The ceiling can fire while a removal is in
1892        /// flight, and the view holds the quit until that removal reports — so the run goes on
1893        /// to do exactly what it was asked. Only the filesystem gets to say whether the job was
1894        /// done; this only fills in the sentence when it says no.
1895        fn why(&self, otherwise: &str, target: &Path) -> String {
1896            let Some(waited) = self.expired else {
1897                return otherwise.to_owned();
1898            };
1899            format!(
1900                "timed out after {waited:?}: the run was still going when the script ran out of \
1901                 patience, and {} still holds {} files. A starved machine and a real regression \
1902                 both look like this from here, so this names neither — the assertion that was \
1903                 waiting is {otherwise:?}",
1904                target.display(),
1905                files_under(target),
1906            )
1907        }
1908    }
1909
1910    impl Events for Script {
1911        /// Always an event waiting — but yielding first.
1912        ///
1913        /// The real `event::poll` blocks for its timeout, so the shipped loop spends most of
1914        /// its life asleep. This one cannot block, and a script that says "yes" instantly
1915        /// turns the loop into a spinner that holds a core against the very walker and deleter
1916        /// it is waiting on — hardest on the loaded machine where they can least afford it.
1917        ///
1918        /// Measured on this box, eighteen concurrent copies of these tests across fourteen
1919        /// cores: a run burns a median of 608 events without the yield and 234 with it. Same
1920        /// work, 2.6× less spinning, and the difference is time handed back to the threads the
1921        /// assertions are waiting for. It costs nothing on an idle machine, where there is no
1922        /// other runnable thread to hand over to.
1923        fn poll(&mut self, _timeout: Duration) -> io::Result<bool> {
1924            std::thread::yield_now();
1925            Ok(true)
1926        }
1927
1928        fn read(&mut self) -> io::Result<Event> {
1929            let started = *self.started.get_or_insert_with(Instant::now);
1930            let waited = started.elapsed();
1931            if waited >= self.patience {
1932                // The first expiry's figure, kept: every read after this one is also past the
1933                // ceiling, and the number worth reporting is how long the run took to give up
1934                // rather than how long the quit then took to be honoured.
1935                self.expired.get_or_insert(waited);
1936            }
1937            let leaving =
1938                self.expired.is_some() || self.until.as_ref().is_some_and(|finished| finished());
1939            let event = if leaving {
1940                key(KeyCode::Char('q'))
1941            } else {
1942                self.events[self.at % self.events.len()].clone()
1943            };
1944            self.at += 1;
1945            Ok(event)
1946        }
1947    }
1948
1949    fn key(code: KeyCode) -> Event {
1950        Event::Key(KeyEvent::new(code, KeyModifiers::NONE))
1951    }
1952
1953    fn at(kind: MouseEventKind, column: u16, row: u16) -> Event {
1954        Event::Mouse(MouseEvent {
1955            kind,
1956            column,
1957            row,
1958            modifiers: KeyModifiers::NONE,
1959        })
1960    }
1961
1962    fn files_under(dir: &Path) -> usize {
1963        let mut count = 0;
1964        let mut stack = vec![dir.to_path_buf()];
1965        while let Some(at) = stack.pop() {
1966            let Ok(entries) = std::fs::read_dir(&at) else {
1967                continue;
1968            };
1969            for entry in entries.flatten() {
1970                if entry.path().is_dir() {
1971                    stack.push(entry.path());
1972                } else {
1973                    count += 1;
1974                }
1975            }
1976        }
1977        count
1978    }
1979
1980    /// A project whose `node_modules` takes long enough to remove that a failure can land in
1981    /// the middle of it.
1982    fn fixture() -> (TempDir, PathBuf) {
1983        let tmp = TempDir::new().unwrap();
1984        let target = tmp.path().join("app/node_modules");
1985        std::fs::create_dir_all(tmp.path().join("app")).unwrap();
1986        std::fs::write(tmp.path().join("app/package.json"), "{}").unwrap();
1987        for n in 0..40 {
1988            let dir = target.join(format!("p{n}"));
1989            std::fs::create_dir_all(&dir).unwrap();
1990            for f in 0..50 {
1991                std::fs::write(dir.join(format!("f{f}.js")), "x").unwrap();
1992            }
1993        }
1994        (tmp, target)
1995    }
1996
1997    #[test]
1998    fn a_script_that_runs_out_of_patience_reports_a_timeout_rather_than_a_verdict() {
1999        // #632: the ceiling is the harness giving up, and until it said so every assertion
2000        // downstream reported it as the *loop* failing at whatever that assertion was about.
2001        // A run that never marks anything is the shape both a starved walker and a broken
2002        // gesture leave behind — the row never arrives either way — so this drives the same
2003        // loop with a phrase that cannot mark, and asserts the report keeps the two facts it
2004        // can see apart from the cause it cannot: nothing was removed, the script stopped
2005        // waiting, and which of those explains the other is left to the reader.
2006        let (tmp, target) = fixture();
2007        let mut terminal = Terminal::new(Flaky {
2008            inner: TestBackend::new(100, 24),
2009            broken: Arc::new(AtomicBool::new(false)),
2010            blind: false,
2011        })
2012        .unwrap();
2013        // `↓` and `↑` move a cursor and nothing else, so this run can only ever end on the
2014        // ceiling — there is no `q` in the phrase and no condition to satisfy.
2015        let mut idle = Script::new(vec![key(KeyCode::Down), key(KeyCode::Up)])
2016            .patience(Duration::from_millis(250));
2017
2018        let outcome = drive(
2019            &mut terminal,
2020            &mut idle,
2021            &mut Chrome::new(Vec::new(), Decor::silent()),
2022            &mut Screen::new(Vec::new(), false),
2023            &Options {
2024                root: tmp.path().to_path_buf(),
2025                min_size: crate::DEFAULT_MIN_SIZE,
2026                size_mode: SizeMode::Skip,
2027                one_file_system: true,
2028                older_than: None,
2029                ignored_files: false,
2030                excludes: std::sync::Arc::new(ignore::gitignore::Gitignore::empty()),
2031            },
2032            Arc::new(Ruleset::builtin().unwrap()),
2033        )
2034        .unwrap();
2035
2036        // It *ended*, which is the whole point of having a ceiling…
2037        assert!(idle.gave_up().is_some(), "the run ended some other way");
2038        // …nothing went, because nothing in the phrase can mark…
2039        assert!(target.exists(), "the phrase cannot mark, so nothing can go");
2040        assert!(
2041            outcome.whole(),
2042            "a run that did nothing is still a whole run"
2043        );
2044
2045        // …and this is the fix. The sentence handed to a downstream assertion reports the two
2046        // things it can actually see — how long it waited, and what is still on disk — and
2047        // keeps the assertion that was waiting, so the reader still knows what was being asked.
2048        let said = idle.why("the pointer marked nothing", &target);
2049        assert!(said.contains("timed out after"), "{said}");
2050        assert!(said.contains("holds 2000 files"), "{said}");
2051        assert!(said.contains("\"the pointer marked nothing\""), "{said}");
2052
2053        // And it stops there, which is the half a first attempt at this got wrong. This run
2054        // genuinely *was* starved — nothing in its phrase can mark — but a pointer that had
2055        // stopped hit-testing leaves exactly the same state, so a message that ruled one out
2056        // would be inventing the one fact it cannot observe, in the direction that hides a
2057        // regression. Naming neither is what keeps the queue gate able to fail on a real one.
2058        assert!(said.contains("names neither"), "{said}");
2059        assert!(
2060            !said.contains("NOT"),
2061            "the timeout exonerated the code under test: {said}"
2062        );
2063
2064        // And the other half, without which this would just be a louder false block: a script
2065        // that did *not* give up hands the sentence straight back, so an ordinary failure
2066        // still says the ordinary thing.
2067        assert_eq!(
2068            Script::new(Vec::new()).why("the pointer marked nothing", &target),
2069            "the pointer marked nothing"
2070        );
2071    }
2072
2073    #[test]
2074    fn a_terminal_that_will_not_say_how_big_a_cell_is_costs_the_tree_no_columns() {
2075        // #656, through the real loop. The terminal is on the allowlist *and* answers the
2076        // window size with zero pixels, which is what a run inside tmux sees whenever `TERM`
2077        // still names the terminal outside it: the allowlist reads Ghostty and says yes, and
2078        // tmux forwards no pixel fields at all.
2079        //
2080        // What went wrong was that the two gates were asked at different times — the
2081        // allowlist before the layout, the pixel size at the draw — so the pane was reserved
2082        // off the first and then declined by the second, and the reader got columns taken
2083        // from the tree with nothing in them and no sentence anywhere saying why.
2084        let (tmp, target) = fixture();
2085        let mut terminal = Terminal::new(Flaky {
2086            inner: TestBackend::new(100, 24),
2087            broken: Arc::new(AtomicBool::new(false)),
2088            blind: true,
2089        })
2090        .unwrap();
2091        // Wide enough for a map, so nothing but the missing pixel size can be what keeps the
2092        // pane off the screen.
2093        const { assert!(100 >= crate::tui::treemap::MIN_WIDTH) };
2094        let mut idle = Script::new(vec![key(KeyCode::Down), key(KeyCode::Up)])
2095            .patience(Duration::from_millis(250));
2096        let mut screen = Screen::new(Vec::new(), true);
2097
2098        drive(
2099            &mut terminal,
2100            &mut idle,
2101            &mut Chrome::new(Vec::new(), Decor::silent()),
2102            &mut screen,
2103            &Options {
2104                root: tmp.path().to_path_buf(),
2105                min_size: crate::DEFAULT_MIN_SIZE,
2106                size_mode: SizeMode::Skip,
2107                one_file_system: true,
2108                older_than: None,
2109                ignored_files: false,
2110                excludes: std::sync::Arc::new(ignore::gitignore::Gitignore::empty()),
2111            },
2112            Arc::new(Ruleset::builtin().unwrap()),
2113        )
2114        .unwrap();
2115        assert!(target.exists(), "the phrase cannot mark, so nothing can go");
2116
2117        // Not one byte, which was already true — the draw refused the zeros and said nothing.
2118        assert!(
2119            screen.sink().is_empty(),
2120            "an image was sized from a guess at the cell"
2121        );
2122        // And this is the part that was not. Row 1 is the column heading, drawn across the
2123        // tree's own pane on one background — so a map beside it leaves the heading short of
2124        // the right edge with the layout's one-column gap between the two, unstyled. An
2125        // unbroken run of that background to the last column is the tree having the whole
2126        // width, which is the thing #656 took away.
2127        let buffer = terminal.backend().inner.buffer().clone();
2128        let heading: Vec<_> = (0..buffer.area.width).map(|x| buffer[(x, 1)].bg).collect();
2129        assert!(
2130            heading
2131                .iter()
2132                .all(|bg| *bg == ratatui::style::Color::Rgb(24, 24, 30)),
2133            "the tree gave up columns for a map that could never be drawn in them: {heading:?}"
2134        );
2135    }
2136
2137    #[test]
2138    fn a_terminal_that_fails_mid_removal_still_waits_for_the_batch() {
2139        let (tmp, target) = fixture();
2140        let whole = files_under(&target);
2141        assert_eq!(whole, 2000);
2142
2143        let broken = Arc::new(AtomicBool::new(false));
2144        // Breaks the terminal as soon as the removal is *demonstrably* under way — a real
2145        // condition rather than a sleep, and the exact moment the finding is about.
2146        let arming = Arc::clone(&broken);
2147        let counting = target.clone();
2148        let watcher = std::thread::spawn(move || {
2149            while files_under(&counting) == whole {
2150                std::thread::yield_now();
2151            }
2152            arming.store(true, Ordering::SeqCst);
2153        });
2154
2155        let mut terminal = Terminal::new(Flaky {
2156            inner: TestBackend::new(100, 24),
2157            broken: Arc::clone(&broken),
2158            blind: false,
2159        })
2160        .unwrap();
2161        // Mark the scan root, then ask-highlight-confirm, on repeat. The root row exists from
2162        // the first frame, and a mark on it covers whatever the walk finds underneath —
2163        // which is the streaming property doing the test's synchronisation for it.
2164        // Nothing to wait for: this run is ended by the terminal failing, which is the whole
2165        // subject of the test, so the script carries no `until`.
2166        let mut keys = Script::new(vec![
2167            key(KeyCode::Char(' ')),
2168            key(KeyCode::Char('x')),
2169            key(KeyCode::Right),
2170            key(KeyCode::Enter),
2171        ]);
2172
2173        let outcome = drive(
2174            &mut terminal,
2175            &mut keys,
2176            &mut Chrome::new(Vec::new(), Decor::silent()),
2177            &mut Screen::new(Vec::new(), false),
2178            &Options {
2179                root: tmp.path().to_path_buf(),
2180                min_size: crate::DEFAULT_MIN_SIZE,
2181                size_mode: SizeMode::Skip,
2182                one_file_system: true,
2183                older_than: None,
2184                ignored_files: false,
2185                excludes: std::sync::Arc::new(ignore::gitignore::Gitignore::empty()),
2186            },
2187            Arc::new(Ruleset::builtin().unwrap()),
2188        );
2189        watcher.join().unwrap();
2190
2191        // First, because a script that gave up quit on its own and the terminal never got the
2192        // chance to fail — which would make every sentence below this one describe the wrong
2193        // run. See [`Script::expired`].
2194        // The loop left through a `?`, which is the path that used to abandon the thread…
2195        assert!(
2196            outcome.is_err(),
2197            "{}",
2198            keys.why("the terminal was supposed to fail", &target)
2199        );
2200        // …and the batch the reader confirmed is finished rather than half done. Without the
2201        // join this is 2,000 files minus however many fitted into a few microseconds.
2202        assert!(
2203            !target.exists(),
2204            "{}",
2205            keys.why(
2206                &format!("{} survived a removal that was abandoned", target.display()),
2207                &target
2208            )
2209        );
2210    }
2211
2212    #[test]
2213    fn a_batch_marked_with_the_pointer_is_removed_through_the_real_loop() {
2214        // The wiring no unit test reaches: the geometry comes back out of a real `draw`, a
2215        // real press is resolved against it, and the action that comes out drives the same
2216        // plan-confirm-delete pipeline `space` does. #602's own lesson was that the bug the
2217        // unit tests all missed was found by driving the whole thing at once.
2218        let (tmp, target) = fixture();
2219        let mut terminal = Terminal::new(Flaky {
2220            inner: TestBackend::new(100, 24),
2221            broken: Arc::new(AtomicBool::new(false)),
2222            blind: false,
2223        })
2224        .unwrap();
2225
2226        // Row 0 of the tree is the scan root, and it exists from the first frame: the header
2227        // is line 0, the column heading is line 1, so the root's mark box is cell (0, 2).
2228        // The reader marks it by pressing there and letting go, which is the whole deferred
2229        // click — a press alone would do nothing.
2230        let box_of_root = (0, 2);
2231        // The second press of the cycle is on the root's *name* rather than its box, so that
2232        // a repeat of the cycle is never read as a double click. That gesture has its own
2233        // tests; this one is about a click.
2234        let name_of_root = (10, 2);
2235        let gone = target.clone();
2236        let mut hand = Script::new(vec![
2237            at(
2238                MouseEventKind::Down(MouseButton::Left),
2239                box_of_root.0,
2240                box_of_root.1,
2241            ),
2242            at(
2243                MouseEventKind::Up(MouseButton::Left),
2244                box_of_root.0,
2245                box_of_root.1,
2246            ),
2247            key(KeyCode::Char('x')),
2248            key(KeyCode::Right),
2249            key(KeyCode::Enter),
2250            at(
2251                MouseEventKind::Down(MouseButton::Left),
2252                name_of_root.0,
2253                name_of_root.1,
2254            ),
2255            at(
2256                MouseEventKind::Up(MouseButton::Left),
2257                name_of_root.0,
2258                name_of_root.1,
2259            ),
2260        ])
2261        // The same condition the sibling test quits on, and for the same reason: a `q`
2262        // inside the cycle would end the run whether or not the walker had published the
2263        // row the pointer is aiming at, so the assertion would fire on scheduling rather
2264        // than on a fault.
2265        .until(move || !gone.exists());
2266
2267        let outcome = drive(
2268            &mut terminal,
2269            &mut hand,
2270            &mut Chrome::new(Vec::new(), Decor::silent()),
2271            &mut Screen::new(Vec::new(), false),
2272            &Options {
2273                root: tmp.path().to_path_buf(),
2274                min_size: crate::DEFAULT_MIN_SIZE,
2275                size_mode: SizeMode::Skip,
2276                one_file_system: true,
2277                older_than: None,
2278                ignored_files: false,
2279                excludes: std::sync::Arc::new(ignore::gitignore::Gitignore::empty()),
2280            },
2281            Arc::new(Ruleset::builtin().unwrap()),
2282        )
2283        .unwrap();
2284
2285        // The message #632 exists for. A starved walker never publishes the row the press is
2286        // aiming at, so the run ends on the ceiling with the target untouched — and stating
2287        // "the pointer marked nothing" flatly about a run that timed out names a cause the run
2288        // never established. It stays as the sentence a *finished* run fails with; a timed-out
2289        // one gets it quoted alongside the timeout instead. See [`Script::why`].
2290        assert!(
2291            !target.exists(),
2292            "{}",
2293            hand.why("the pointer marked nothing", &target)
2294        );
2295        assert!(outcome.whole(), "{outcome:?}");
2296        assert!(
2297            tmp.path().join("app/package.json").exists(),
2298            "too much went"
2299        );
2300    }
2301
2302    #[test]
2303    fn a_marked_batch_is_removed_through_the_real_loop() {
2304        // The same machinery, ending the ordinary way: it is worth one test that the keys, the
2305        // planner and the deleter meet correctly, because everything else about the loop is
2306        // asserted through the pieces.
2307        let (tmp, target) = fixture();
2308        // The same wrapper with the flag never set — a terminal that works, whose `Error` is
2309        // the `io::Error` the loop is written against.
2310        let mut terminal = Terminal::new(Flaky {
2311            inner: TestBackend::new(100, 24),
2312            broken: Arc::new(AtomicBool::new(false)),
2313            blind: false,
2314        })
2315        .unwrap();
2316        let gone = target.clone();
2317        let mut keys = Script::new(vec![
2318            key(KeyCode::Char(' ')),
2319            key(KeyCode::Char('x')),
2320            key(KeyCode::Right),
2321            key(KeyCode::Enter),
2322        ])
2323        // The target is `rmdir`ed only once every child under it is gone, so this becomes
2324        // true exactly when the batch the test is about has finished. A `q` before then
2325        // would be the script racing the walker rather than the loop doing its job — and
2326        // a `q` after it is held by the view anyway until the removal reports.
2327        .until(move || !gone.exists());
2328
2329        let mut chrome = Chrome::new(
2330            Vec::new(),
2331            Decor {
2332                sync: true,
2333                title: Some(XTERM_STACK),
2334                progress: true,
2335                notify: None,
2336                graphics: true,
2337            },
2338        );
2339        // A terminal that reads the graphics protocol as well, so the map's own wiring is
2340        // driven by the same run: the pane is split off the body, the image is written
2341        // inside the synchronized update, and it is taken down when the confirmation goes up.
2342        let mut screen = Screen::new(Vec::new(), true);
2343        let outcome = drive(
2344            &mut terminal,
2345            &mut keys,
2346            &mut chrome,
2347            &mut screen,
2348            &Options {
2349                root: tmp.path().to_path_buf(),
2350                min_size: crate::DEFAULT_MIN_SIZE,
2351                size_mode: SizeMode::Skip,
2352                one_file_system: true,
2353                older_than: None,
2354                ignored_files: false,
2355                excludes: std::sync::Arc::new(ignore::gitignore::Gitignore::empty()),
2356            },
2357            Arc::new(Ruleset::builtin().unwrap()),
2358        )
2359        .unwrap();
2360
2361        // The sibling test's rule: "nothing was removed" is a true sentence about the wrong
2362        // subject when the run never got as far as removing anything. See [`Script::why`].
2363        assert!(
2364            !target.exists(),
2365            "{}",
2366            keys.why("nothing was removed", &target)
2367        );
2368        assert!(outcome.whole(), "{outcome:?}");
2369        assert!(
2370            tmp.path().join("app/package.json").exists(),
2371            "too much went"
2372        );
2373
2374        // The decorations, through the real loop rather than through the chrome on its own.
2375        // Balance is the property that matters: an update begun once more than it is ended is
2376        // a terminal that stops repainting, and it is a *run*'s worth of frames that proves
2377        // that rather than one call and its pair.
2378        let said = String::from_utf8(chrome.sink().clone()).unwrap();
2379        assert!(said.contains("\x1b[?2026h"), "nothing was ever wrapped");
2380        // Counting the two halves would pass on a run that opened every frame and then closed
2381        // them all at the end. What has to hold is that they alternate: one open, one close,
2382        // never two of either in a row.
2383        let mut open = 0i32;
2384        for frame in said.split("\x1b[?2026").skip(1) {
2385            match frame.as_bytes().first() {
2386                Some(b'h') => open += 1,
2387                Some(b'l') => open -= 1,
2388                other => panic!("a private mode nobody wrote: {other:?}"),
2389            }
2390            assert!((0..=1).contains(&open), "the frames did not alternate");
2391        }
2392        assert_eq!(open, 0, "a frame was left open");
2393        assert!(said.contains("\x1b]0;pristine — "), "{said:?}");
2394        // The bar reaches 0 only when the guard restores it, which this test does not use, so
2395        // what the run itself must not do is leave it at a percentage it never got to.
2396        assert!(said.contains("\x1b]9;4;"), "no progress was ever reported");
2397
2398        // The map, through the same run. What matters is not that a picture appeared but
2399        // that every one of them is *balanced*: an image transmitted and never deleted is a
2400        // megabyte left in the terminal's memory after this process has gone.
2401        let drawn = String::from_utf8_lossy(screen.sink()).into_owned();
2402        assert!(drawn.contains("\x1b_Ga=T,"), "no map was ever drawn");
2403        assert!(
2404            drawn.matches("a=d,d=I").count() >= drawn.matches("a=T,").count(),
2405            "an image was transmitted without a delete to match it"
2406        );
2407        // Every placement is inside a cursor save/restore, because the cursor belongs to
2408        // ratatui and a frame drawn from where an image left it is a frame in the wrong place.
2409        assert_eq!(
2410            drawn.matches("\x1b7").count(),
2411            drawn.matches("\x1b8").count(),
2412            "the cursor was not put back after a placement"
2413        );
2414    }
2415}