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}