pristine/tui/chrome.rs
1//! Everything the terminal shows that is not a cell of the frame.
2//!
3//! Five decorations, one rule. The rule first, because it is the whole design: **every one of
4//! these degrades to nothing.** Nothing here probes a capability, waits for an answer, or
5//! sniffs a version beyond reading two environment variables; a terminal that does not know a
6//! sequence ignores it, and a terminal this cannot identify is simply told less. None of it
7//! runs at all when stdout is not a terminal, because an escape sequence written into a pipe
8//! is corruption of somebody's data.
9//!
10//! # What each one buys
11//!
12//! - **Synchronized output (DEC 2026)** wraps every frame. Without it a 10 fps repaint tears,
13//! and it tears worst over ssh, which is where a sweep of a disk that is filling up tends to
14//! run. This is the one decoration with no allowlist: the private mode is defined to be
15//! ignorable and every terminal that parses `CSI` already drops what it does not know.
16//! - **OSC 9;4 progress** puts a real bar on the dock or the taskbar. A full price of one real
17//! `~/repos` is 55.8 s, which is long enough that the reader has gone somewhere else, and
18//! the percentage is one the pool already knows: claims priced over claims found.
19//! - **OSC 0 title** makes a backgrounded run readable from the tab bar. It is restored on the
20//! way out, including the error path — see [`Chrome::restore`] — and it is only ever set on a
21//! terminal that can restore it. See [`Title`].
22//! - **One notification**, and only when the run was long enough to be worth interrupting
23//! somebody for *and* they are demonstrably looking elsewhere. A notification for a 200 ms
24//! scan is spam.
25//!
26//! - **The kitty graphics protocol** is the one decoration that is *cells* rather than
27//! chrome: it is what puts [`super::treemap`]'s picture on the screen. It is decided here
28//! anyway, because what decides it is which terminal this is, which is this table's
29//! subject — and two tables reading one environment are two tables that can disagree.
30//!
31//! # Why four of the five are allowlisted, and one is not
32//!
33//! **Only the synchronized update goes everywhere**, because it is the only one that leaves
34//! nothing behind: an unknown private mode is dropped by every parser that understands `CSI`,
35//! and there is no state to give back afterwards. The other four all fail by *persisting* —
36//! an image most loudly of all, since a terminal that does not decode `APC G` prints a
37//! megabyte of base64 into the reader's scrollback.
38//!
39//! Two of them fail by being misread, and OSC 9 colliding with itself is why.
40//! `OSC 9 ; <text>` is a desktop notification in iTerm2, `WezTerm` and Ghostty;
41//! `OSC 9 ; 4 ; <state> ; <percent>` is `ConEmu`'s progress bar, read by `WezTerm`, Ghostty,
42//! `ConEmu` and Windows Terminal. A terminal that knows only the first reads a progress report
43//! as a notification saying `4;1;41`, which is worse than no bar at all.
44//!
45//! The third fails by being *unreturnable*, which is subtler and worse. Setting a title is easy
46//! everywhere; putting the old one back needs a title stack that not every terminal keeps, and
47//! a terminal without one is simply left holding `pristine — freed 41.2 GiB` forever. So the
48//! title is not a flag — it is the push/pop pair itself ([`Title`]), present only for terminals
49//! documented to keep the stack, which makes "set a title nothing can clear" unrepresentable
50//! rather than merely avoided.
51
52use std::io::{self, IsTerminal, Write};
53use std::time::Duration;
54
55use ratatui::crossterm::event::{DisableFocusChange, EnableFocusChange};
56use ratatui::crossterm::execute;
57use ratatui::crossterm::terminal::{BeginSynchronizedUpdate, EndSynchronizedUpdate, SetTitle};
58
59use super::state::{View, percent};
60use crate::size::human;
61
62/// How long a run has to have taken before finishing it is worth a notification.
63///
64/// The floor exists because the failure mode is spam, not silence: a default scan of a project
65/// is over in well under a second and nobody wants to be told. Five seconds is about the
66/// shortest run somebody walks away from, and the run this feature is for takes a minute.
67const NOTIFY_AFTER: Duration = Duration::from_secs(5);
68
69/// How a terminal's window title is taken, and given back.
70///
71/// **One value carrying both halves, which is the entire point of the type.** A title is a
72/// piece of the reader's terminal that this run borrows, and the rule for everything borrowed
73/// here is that it has to be returnable. No terminal will tell an application what its title
74/// currently is — there is no query to ask — so the only way to put one back is to have asked
75/// the terminal to remember it first, which is what `xterm`'s title stack is for.
76///
77/// A terminal with no stack ignores the push, ignores the pop, and keeps whatever this run last
78/// set: `pristine — freed 41.2 GiB` in a tab bar for the rest of that terminal's life. That is
79/// not a decoration degrading to nothing, it is a decoration that never leaves. So the two
80/// sequences are held together as one capability, and a terminal that is not known to have it
81/// is never sent a title at all — the setting cannot be switched on without the putting back.
82#[derive(Clone, Copy, Debug, PartialEq, Eq)]
83pub struct Title {
84 /// Saves the title this run is about to replace.
85 push: &'static str,
86 /// Puts it back.
87 pop: &'static str,
88}
89
90/// `xterm`'s title stack, which is the only one there is.
91pub const XTERM_STACK: Title = Title {
92 push: "\x1b[22;2t",
93 pop: "\x1b[23;2t",
94};
95
96/// Which decorations a terminal is known to read.
97///
98/// Data rather than a chain of `if`s at each call site, and computed once: the environment
99/// does not change under a running process, and a decision taken per frame is a decision that
100/// can differ per frame.
101#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
102pub struct Decor {
103 /// Wrap frames in DEC 2026.
104 pub sync: bool,
105 /// How to set the window title and put it back, if this terminal can do both.
106 pub title: Option<Title>,
107 /// Report progress as OSC 9;4.
108 pub progress: bool,
109 /// How to raise a desktop notification, if this terminal can.
110 pub notify: Option<Notify>,
111 /// Read the kitty graphics protocol — which is how the treemap pane gets on the screen.
112 ///
113 /// A column of this table rather than a second one keyed on the same two environment
114 /// variables, because two tables read from one environment are two tables that can
115 /// disagree about which terminal this is. It is also the only decoration here that is
116 /// *cells* rather than chrome, and it is here anyway for that reason: what decides it is
117 /// the terminal's identity, which is this table's whole subject.
118 ///
119 /// Absence is a refusal to guess, as everywhere else. The protocol does define a query
120 /// for "do you read this", and it is a round trip with no bound on the silence — the
121 /// blocking probe this module exists to avoid. See [`super::treemap`].
122 pub graphics: bool,
123}
124
125/// The spelling of a desktop notification that a given terminal reads.
126#[derive(Clone, Copy, Debug, PartialEq, Eq)]
127pub enum Notify {
128 /// `OSC 9 ; <text> BEL` — iTerm2, `WezTerm`, Ghostty.
129 Osc9,
130 /// `OSC 777 ; notify ; <title> ; <body> BEL` — urxvt's, and read by several others.
131 Osc777,
132}
133
134/// One terminal, and everything it is known to read.
135struct Known {
136 /// What it calls itself in `TERM_PROGRAM`, or `""` for one that sets no such variable.
137 program: &'static str,
138 /// A `TERM` that names this terminal and nothing else, or `""`. `xterm-256color` is not
139 /// such a name — half the terminals here can be found wearing it — which is why this is a
140 /// second key rather than the first one.
141 term: &'static str,
142 /// What it reads.
143 decor: Decor,
144}
145
146impl Known {
147 /// Whether this row is the terminal the environment describes.
148 fn names(&self, program: &str, term: &str) -> bool {
149 (!self.program.is_empty() && self.program == program)
150 || (!self.term.is_empty() && self.term == term)
151 }
152}
153
154/// The table. **Absence from it is a refusal to guess, not a claim about a terminal**, and
155/// every column is a claim that the terminal *documents* the sequence in question.
156///
157/// The asymmetry in the cost of being wrong is what sets the default. A decoration wrongly
158/// withheld is a feature somebody does not get; a decoration wrongly sent is a pop-up full of
159/// punctuation, or a window title nobody can clear. So a row is added when a terminal's own
160/// documentation says it reads the sequence, and never because it probably does.
161const KNOWN: &[Known] = &[
162 Known {
163 program: "ghostty",
164 term: "xterm-ghostty",
165 decor: Decor {
166 sync: true,
167 title: Some(XTERM_STACK),
168 progress: true,
169 notify: Some(Notify::Osc9),
170 graphics: true,
171 },
172 },
173 Known {
174 program: "WezTerm",
175 term: "wezterm",
176 decor: Decor {
177 sync: true,
178 title: Some(XTERM_STACK),
179 progress: true,
180 notify: Some(Notify::Osc9),
181 graphics: true,
182 },
183 },
184 Known {
185 program: "iTerm.app",
186 term: "",
187 decor: Decor {
188 sync: true,
189 title: Some(XTERM_STACK),
190 // iTerm2 reads `OSC 9 ; …` as a notification, so a progress report would reach the
191 // reader as a pop-up saying `4;1;41`.
192 progress: false,
193 notify: Some(Notify::Osc9),
194 // iTerm2's inline images are its own OSC 1337, not this protocol. That is the
195 // obvious next terminal to reach and it is a different encoder, so it is a
196 // follow-on rather than a row that can be flipped.
197 graphics: false,
198 },
199 },
200 Known {
201 program: "",
202 term: "xterm-kitty",
203 decor: Decor {
204 sync: true,
205 title: Some(XTERM_STACK),
206 progress: false,
207 // kitty's notification is OSC 99, which nothing here speaks.
208 notify: None,
209 graphics: true,
210 },
211 },
212 Known {
213 program: "Apple_Terminal",
214 term: "",
215 // Looked at, and the answer is no to everything but the private mode. Kept as a row
216 // rather than left to the default so the next person does not have to look again.
217 decor: Decor {
218 sync: true,
219 title: None,
220 progress: false,
221 notify: None,
222 graphics: false,
223 },
224 },
225];
226
227impl Decor {
228 /// What this process's terminal reads, from the environment and nothing else.
229 #[must_use]
230 pub fn detect() -> Self {
231 if !io::stdout().is_terminal() {
232 return Self::default();
233 }
234 Self::read(&|key| std::env::var(key).ok())
235 }
236
237 /// Nothing at all: the front end that is not a terminal, and the tests that are about
238 /// something else.
239 #[must_use]
240 pub fn silent() -> Self {
241 Self::default()
242 }
243
244 /// The decision, against an environment a test can supply.
245 fn read(env: &dyn Fn(&str) -> Option<String>) -> Self {
246 // A `TERM` that is absent or `dumb` is the one thing in the environment that is a
247 // statement about escape sequences rather than about a product, and it says no.
248 let term = env("TERM").unwrap_or_default();
249 if term.is_empty() || term == "dumb" {
250 return Self::silent();
251 }
252 // What an unidentified terminal gets: the one decoration that changes nothing it could
253 // be left holding. An unknown private mode is dropped by every parser that understands
254 // `CSI` at all, and there is no state to give back afterwards.
255 let anonymous = Self {
256 sync: true,
257 ..Self::silent()
258 };
259 // Inside a multiplexer, `TERM_PROGRAM` names whatever started the *server* — which is
260 // not necessarily what is parsing these bytes, and may not still be running. tmux drops
261 // the OSC sequences it does not implement, so most of this would go nowhere; the title
262 // is the exception, because tmux does set a pane title from OSC 0 and would then be
263 // left holding it.
264 if term.starts_with("screen") || term.starts_with("tmux") {
265 return anonymous;
266 }
267 let program = env("TERM_PROGRAM").unwrap_or_default();
268 if let Some(known) = KNOWN.iter().find(|known| known.names(&program, &term)) {
269 return known.decor;
270 }
271 // Neither of these sets `TERM_PROGRAM`, and both read ConEmu's bar because one of them
272 // is ConEmu. Neither is known to keep a title stack.
273 if env("WT_SESSION").is_some() || env("ConEmuANSI").is_some() {
274 return Self {
275 progress: true,
276 ..anonymous
277 };
278 }
279 anonymous
280 }
281}
282
283/// What the tab bar and the taskbar say about a run, at one moment.
284///
285/// A ladder rather than a set of flags: at any moment exactly one of these is the thing a
286/// reader who is elsewhere wants to know, and the order is what makes that true.
287#[derive(Clone, Copy, Debug, PartialEq, Eq)]
288pub enum Status {
289 /// A removal is running, and this much of its batch is behind it.
290 ///
291 /// The one bar here with a denominator that does not move. The batch's size is fixed when
292 /// the reader answers the confirmation, where the pricing bar below counts against a total
293 /// the walk is still adding to — so this is the fraction a reader who has left the terminal
294 /// can actually plan around, and a running byte total on its own cannot say whether a long
295 /// delete is a third of the way through or nearly done.
296 Deleting(u8),
297 /// The pricing pool is behind the walk, by this percentage.
298 Pricing(u8),
299 /// Walking, with nothing outstanding to price.
300 Scanning(u64),
301 /// Nothing running, and a removal has happened this session.
302 Freed(u64),
303 /// Nothing running.
304 Idle(u64),
305}
306
307impl Status {
308 /// What the view is showing, said in one line.
309 ///
310 /// The percentage's denominator **grows**, because a claim is published the moment it is
311 /// judged and priced later, so the figure can go down as the walk finds faster than the
312 /// pool prices. That is honest rather than tidy: the alternative is a denominator that is
313 /// only known when the walk finishes, which is 7.5 s into a 63 s run — a bar that
314 /// appears once it has stopped being needed.
315 #[must_use]
316 pub fn of(view: &View, freed: u64) -> Self {
317 let total = view.total();
318 if let Some(removing) = view.removing() {
319 return Self::Deleting(removing.percent());
320 }
321 if view.is_scanning() {
322 let priced = total.claims - total.unpriced;
323 return match (total.unpriced, total.claims) {
324 (0, _) | (_, 0) => Self::Scanning(total.bytes),
325 (_, claims) => Self::Pricing(percent(priced, claims)),
326 };
327 }
328 if freed > 0 {
329 return Self::Freed(freed);
330 }
331 Self::Idle(total.bytes)
332 }
333
334 /// The window title.
335 fn title(&self) -> String {
336 match self {
337 Self::Deleting(percent) => format!("pristine — deleting {percent}%"),
338 Self::Pricing(percent) => format!("pristine — pricing {percent}%"),
339 Self::Scanning(bytes) | Self::Idle(bytes) => format!("pristine — {}", human(*bytes)),
340 Self::Freed(bytes) => format!("pristine — freed {}", human(*bytes)),
341 }
342 }
343
344 /// The taskbar's bar.
345 fn bar(&self) -> Bar {
346 match self {
347 // Walking has no honest fraction attached — nothing knows how many directories
348 // are under a path until they have been visited — which is what the indeterminate
349 // state is for. Reporting 0% instead would read as stuck.
350 Self::Scanning(_) => Bar::Working,
351 Self::Deleting(percent) | Self::Pricing(percent) => Bar::At(*percent),
352 Self::Freed(_) | Self::Idle(_) => Bar::Off,
353 }
354 }
355}
356
357/// The state of the taskbar's progress bar, in `ConEmu`'s vocabulary.
358#[derive(Clone, Copy, Debug, PartialEq, Eq)]
359enum Bar {
360 /// No bar (state 0).
361 Off,
362 /// Something is happening and nobody can say how much of it is left (state 3).
363 Working,
364 /// This much of it is done (state 1).
365 At(u8),
366 /// The run ended without doing everything it was asked (state 2).
367 Failed,
368}
369
370impl Bar {
371 /// The two numbers OSC 9;4 carries. Both are always sent: the percentage is optional in
372 /// the sequence and not every reader of it agrees what it defaults to.
373 fn code(self) -> (u8, u8) {
374 match self {
375 Self::Off => (0, 0),
376 Self::Working => (3, 0),
377 Self::At(percent) => (1, percent),
378 // Full rather than empty, because a zero-length red bar is one a reader cannot
379 // see, and "this run ended badly" is the whole message.
380 Self::Failed => (2, 100),
381 }
382 }
383}
384
385/// Whether the reader is looking at this terminal.
386///
387/// Assumed [`Focus::Here`] until the terminal says otherwise, which is deliberately the
388/// conservative end: a terminal that does not report focus never contradicts the assumption, so
389/// it never notifies, and a missing notification is the failure this feature is allowed to
390/// have. The one it is not allowed to have is interrupting somebody who is already watching
391/// the thing finish.
392#[derive(Clone, Copy, Debug, PartialEq, Eq)]
393enum Focus {
394 /// The reader is here, or has not proved otherwise.
395 Here,
396 /// The terminal reported losing focus and has not reported getting it back.
397 Away,
398}
399
400/// The terminal's decorations, and the promise to undo them.
401///
402/// Generic over its sink so the sequences are assertable. That is not a courtesy to the tests:
403/// every one of these writes is invisible to every other kind of test — a title that is never
404/// restored, a progress bar left at 41% forever, a frame that begins a synchronized update and
405/// never ends it — and the last of those leaves the reader looking at a frozen screen.
406#[derive(Debug)]
407pub struct Chrome<W: Write> {
408 out: W,
409 decor: Decor,
410 /// The title as last written, so a repaint ten times a second does not rewrite it.
411 title: Option<String>,
412 /// The bar as last written, for the same reason.
413 bar: Option<Bar>,
414 /// Whether the states this has to undo were actually entered.
415 entered: bool,
416 /// Whether a frame is open. A synchronized update that is begun and not ended is a
417 /// terminal showing the frame before last, indefinitely.
418 framing: bool,
419 focus: Focus,
420 /// Whether the run is ending badly, which the bar says on the way out.
421 failed: bool,
422}
423
424impl<W: Write> Chrome<W> {
425 /// A chrome that writes what `decor` allows, and nothing else, to `out`.
426 pub fn new(out: W, decor: Decor) -> Self {
427 Self {
428 out,
429 decor,
430 title: None,
431 bar: None,
432 entered: false,
433 framing: false,
434 focus: Focus::Here,
435 failed: false,
436 }
437 }
438
439 /// Takes the states that have to be given back: the title, and focus reporting.
440 ///
441 /// Focus reporting is only asked for when a notification could actually be sent, because
442 /// it is the answer to exactly one question — is anybody looking — and a terminal that
443 /// cannot show a notification is not being asked it.
444 ///
445 /// The flag is set **before** the writes rather than after each one, which is the opposite
446 /// of [`super::Restore`]'s rule and deliberate: a half-written `enter` leaves the terminal
447 /// in a state this cannot know, and of the two ways to be wrong, undoing something that
448 /// never happened costs five bytes at a terminal already being restored while skipping the
449 /// undo leaves a shell answering every click with escape gibberish.
450 ///
451 /// # Errors
452 ///
453 /// Anything the terminal refuses.
454 pub fn enter(&mut self) -> io::Result<()> {
455 self.entered = true;
456 if let Some(title) = self.decor.title {
457 self.put(title.push)?;
458 }
459 if self.decor.notify.is_some() {
460 execute!(self.out, EnableFocusChange)?;
461 }
462 Ok(())
463 }
464
465 /// Opens a synchronized update. Everything drawn until [`Chrome::end_frame`] lands at once.
466 ///
467 /// # Errors
468 ///
469 /// Anything the terminal refuses.
470 pub fn begin_frame(&mut self) -> io::Result<()> {
471 if !self.decor.sync {
472 return Ok(());
473 }
474 self.framing = true;
475 execute!(self.out, BeginSynchronizedUpdate)
476 }
477
478 /// Closes it. Must run even when the draw between the two failed.
479 ///
480 /// # Errors
481 ///
482 /// Anything the terminal refuses.
483 pub fn end_frame(&mut self) -> io::Result<()> {
484 if !std::mem::take(&mut self.framing) {
485 return Ok(());
486 }
487 execute!(self.out, EndSynchronizedUpdate)
488 }
489
490 /// Says where the run has got to, writing only what changed.
491 ///
492 /// # Errors
493 ///
494 /// Anything the terminal refuses.
495 pub fn show(&mut self, status: Status) -> io::Result<()> {
496 // Gated on the *stack*, not on a separate "may I set a title" flag: the terminals that
497 // cannot give one back are exactly the terminals that are never given one to hold.
498 if self.decor.title.is_some() {
499 let title = status.title();
500 if self.title.as_ref() != Some(&title) {
501 execute!(self.out, SetTitle(text(&title)))?;
502 self.title = Some(title);
503 }
504 }
505 self.bar(status.bar())
506 }
507
508 /// Writes a bar, if it is not the one already showing.
509 fn bar(&mut self, bar: Bar) -> io::Result<()> {
510 if !self.decor.progress || self.bar == Some(bar) {
511 return Ok(());
512 }
513 let (state, percent) = bar.code();
514 self.put(&format!("\x1b]9;4;{state};{percent}\x07"))?;
515 self.bar = Some(bar);
516 Ok(())
517 }
518
519 /// What the terminal reported about the reader's attention.
520 pub fn focused(&mut self, here: bool) {
521 self.focus = if here { Focus::Here } else { Focus::Away };
522 }
523
524 /// Tells the reader something finished, if they are not here to see it and it took long
525 /// enough to be worth saying.
526 ///
527 /// # Errors
528 ///
529 /// Anything the terminal refuses.
530 pub fn announce(&mut self, body: &str, took: Duration) -> io::Result<()> {
531 if self.focus == Focus::Here || took < NOTIFY_AFTER {
532 return Ok(());
533 }
534 match self.decor.notify {
535 None => Ok(()),
536 Some(Notify::Osc9) => self.put(&format!("\x1b]9;pristine: {}\x07", text(body))),
537 Some(Notify::Osc777) => {
538 self.put(&format!("\x1b]777;notify;pristine;{}\x07", text(body)))
539 }
540 }
541 }
542
543 /// Records that the run is ending without having done everything it was asked.
544 ///
545 /// The bar says so rather than simply going out, because the reader who wanted a bar is by
546 /// definition the reader who is not looking at the exit status.
547 pub fn failed(&mut self) {
548 self.failed = true;
549 }
550
551 /// Puts back everything this took, and can be called twice.
552 ///
553 /// Idempotent because it runs from two places by design: the ordinary way out, and the
554 /// guard that owns it being dropped by a `?` or a panic. Every step is attempted and the
555 /// first refusal reported, for [`super::Restore`]'s reason — a terminal half restored is
556 /// no better than one not restored at all, and a failing call says nothing about whether
557 /// the next would.
558 ///
559 /// # Errors
560 ///
561 /// The first thing the terminal refused.
562 pub fn restore(&mut self) -> io::Result<()> {
563 let mut first = self.end_frame();
564 if !std::mem::take(&mut self.entered) {
565 return first;
566 }
567 let bar = if self.failed { Bar::Failed } else { Bar::Off };
568 first = first.and(self.bar(bar));
569 if self.decor.notify.is_some() {
570 first = first.and(execute!(self.out, DisableFocusChange));
571 }
572 if let Some(title) = self.decor.title {
573 first = first.and(self.put(title.pop));
574 }
575 first
576 }
577
578 /// Writes a sequence and flushes it, because a frame that is waiting in a buffer is a
579 /// frame that has not happened.
580 fn put(&mut self, sequence: &str) -> io::Result<()> {
581 self.out.write_all(sequence.as_bytes())?;
582 self.out.flush()
583 }
584
585 /// What has been written, for the tests that are about exactly that.
586 #[cfg(test)]
587 pub(crate) fn sink(&self) -> &W {
588 &self.out
589 }
590}
591
592/// Strips the control characters out of anything going inside a title or a notification.
593///
594/// Nothing in this file interpolates a path today, and this is here for the day something
595/// does: a `BEL` or an `ESC` in a directory name would end the sequence early and leave the
596/// rest of the name being read as commands. A cleaner is pointed at exactly the directories
597/// whose names it did not choose.
598fn text(said: &str) -> String {
599 said.chars().filter(|c| !c.is_control()).collect()
600}
601
602#[cfg(test)]
603mod tests {
604 use super::{Chrome, Decor, Notify, Status, XTERM_STACK, text};
605 use crate::fixture::{hit, priced};
606 use crate::size::Size;
607 use crate::tree::Tree;
608 use crate::tui::keymap::{Action, Turn};
609 use crate::tui::state::{Planned, View};
610 use std::collections::HashMap;
611 use std::path::PathBuf;
612 use std::time::Duration;
613
614 /// Everything on, which is what a terminal this can identify gets.
615 fn everything() -> Decor {
616 Decor {
617 sync: true,
618 title: Some(XTERM_STACK),
619 progress: true,
620 notify: Some(Notify::Osc9),
621 graphics: true,
622 }
623 }
624
625 fn chrome(decor: Decor) -> Chrome<Vec<u8>> {
626 Chrome::new(Vec::new(), decor)
627 }
628
629 fn written(chrome: &Chrome<Vec<u8>>) -> String {
630 String::from_utf8(chrome.sink().clone()).unwrap()
631 }
632
633 fn env(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> {
634 let map: HashMap<String, String> = pairs
635 .iter()
636 .map(|(k, v)| ((*k).to_owned(), (*v).to_owned()))
637 .collect();
638 move |key: &str| map.get(key).cloned()
639 }
640
641 fn view() -> View {
642 View::new(Tree::new("/scan"))
643 }
644
645 #[test]
646 fn a_terminal_that_reads_nothing_is_written_nothing() {
647 // The property the whole module rests on. Not one escape byte reaches a stdout that
648 // is a pipe, whatever the run does.
649 let mut chrome = chrome(Decor::silent());
650 chrome.enter().unwrap();
651 chrome.begin_frame().unwrap();
652 chrome.show(Status::Pricing(41)).unwrap();
653 chrome.end_frame().unwrap();
654 chrome.focused(false);
655 chrome.announce("done", Duration::from_secs(60)).unwrap();
656 chrome.failed();
657 chrome.restore().unwrap();
658
659 assert_eq!(written(&chrome), "", "an escape reached a pipe");
660 }
661
662 #[test]
663 fn every_frame_is_wrapped_in_a_synchronized_update() {
664 let mut chrome = chrome(everything());
665 chrome.begin_frame().unwrap();
666 chrome.end_frame().unwrap();
667
668 assert_eq!(written(&chrome), "\x1b[?2026h\x1b[?2026l");
669 }
670
671 #[test]
672 fn a_frame_left_open_is_closed_by_the_restore() {
673 // The failure this prevents is the worst one here: a terminal inside a synchronized
674 // update shows the frame before last and keeps showing it, so a draw that fails
675 // between the two halves freezes the screen rather than reporting anything.
676 let mut chrome = chrome(everything());
677 chrome.enter().unwrap();
678 chrome.begin_frame().unwrap();
679 chrome.restore().unwrap();
680
681 let said = written(&chrome);
682 assert!(
683 said.contains("\x1b[?2026l"),
684 "the frame was left open: {said:?}"
685 );
686 assert_eq!(said.matches("\x1b[?2026l").count(), 1);
687 }
688
689 #[test]
690 fn the_title_is_written_once_per_change() {
691 // Ten frames a second times a title nothing has changed is a terminal being asked to
692 // redraw its own tab bar for no reason.
693 let mut chrome = chrome(everything());
694 chrome.show(Status::Idle(1024)).unwrap();
695 chrome.show(Status::Idle(1024)).unwrap();
696 assert_eq!(written(&chrome).matches("\x1b]0;").count(), 1);
697
698 chrome.show(Status::Freed(2048)).unwrap();
699 let said = written(&chrome);
700 assert_eq!(said.matches("\x1b]0;").count(), 2);
701 assert!(said.contains("pristine — freed 2.0 KiB"), "{said:?}");
702 }
703
704 #[test]
705 fn the_title_is_put_back_on_the_way_out_and_only_once() {
706 let mut chrome = chrome(everything());
707 chrome.enter().unwrap();
708 chrome.show(Status::Scanning(0)).unwrap();
709 chrome.restore().unwrap();
710 // The second call is the guard's `Drop` after an ordinary `finish`, which is the
711 // path every early return takes.
712 chrome.restore().unwrap();
713
714 let said = written(&chrome);
715 assert_eq!(said.matches("\x1b[22;2t").count(), 1, "{said:?}");
716 assert_eq!(said.matches("\x1b[23;2t").count(), 1, "{said:?}");
717 }
718
719 #[test]
720 fn pricing_reports_a_percentage_and_the_end_of_a_run_takes_the_bar_away() {
721 let mut chrome = chrome(everything());
722 chrome.enter().unwrap();
723 chrome.show(Status::Scanning(10)).unwrap();
724 assert!(
725 written(&chrome).contains("\x1b]9;4;3;0\x07"),
726 "indeterminate"
727 );
728
729 chrome.show(Status::Pricing(41)).unwrap();
730 assert!(written(&chrome).contains("\x1b]9;4;1;41\x07"));
731
732 chrome.restore().unwrap();
733 assert!(written(&chrome).ends_with("\x1b[23;2t"));
734 assert!(
735 written(&chrome).contains("\x1b]9;4;0;0\x07"),
736 "the bar was left up"
737 );
738 }
739
740 #[test]
741 fn a_run_that_ends_with_failures_leaves_the_bar_saying_so() {
742 let mut chrome = chrome(everything());
743 chrome.enter().unwrap();
744 chrome.failed();
745 chrome.restore().unwrap();
746
747 assert!(written(&chrome).contains("\x1b]9;4;2;100\x07"));
748 }
749
750 #[test]
751 fn a_terminal_that_does_not_read_the_bar_is_not_sent_one() {
752 // iTerm2's shape: it would read `OSC 9 ; 4 ; …` as a notification and pop up a box
753 // saying `4;1;41`.
754 let mut chrome = chrome(Decor {
755 progress: false,
756 ..everything()
757 });
758 chrome.enter().unwrap();
759 chrome.show(Status::Pricing(41)).unwrap();
760 chrome.restore().unwrap();
761
762 let said = written(&chrome);
763 assert!(!said.contains("\x1b]9;4"), "{said:?}");
764 assert!(said.contains("pristine — pricing 41%"));
765 }
766
767 #[test]
768 fn a_terminal_that_cannot_hand_a_title_back_is_never_given_one() {
769 // The one decoration that fails by *persisting* rather than by being ignored. A title
770 // set on a terminal with no stack is `pristine — freed 41.2 GiB` in somebody's tab bar
771 // for the rest of that terminal's life, which is the opposite of degrading to nothing.
772 let mut chrome = chrome(Decor {
773 title: None,
774 ..everything()
775 });
776 chrome.enter().unwrap();
777 chrome.show(Status::Freed(2048)).unwrap();
778 chrome.restore().unwrap();
779
780 let said = written(&chrome);
781 assert!(!said.contains("\x1b]0;"), "a title was set: {said:?}");
782 assert!(
783 !said.contains("22;2t") && !said.contains("23;2t"),
784 "{said:?}"
785 );
786 // The rest still works: this is a narrowing of one decoration, not of the module.
787 assert!(said.contains("\x1b]9;4;0;0\x07"));
788 }
789
790 #[test]
791 fn a_notification_waits_for_a_run_worth_interrupting_somebody_for() {
792 let mut chrome = chrome(everything());
793 chrome.focused(false);
794 chrome
795 .announce("scanned", Duration::from_millis(200))
796 .unwrap();
797 assert_eq!(written(&chrome), "", "a 200 ms scan raised a notification");
798
799 chrome.announce("scanned", Duration::from_secs(60)).unwrap();
800 assert_eq!(written(&chrome), "\x1b]9;pristine: scanned\x07");
801 }
802
803 #[test]
804 fn a_reader_who_is_watching_is_not_notified() {
805 let mut chrome = chrome(everything());
806 // Never told otherwise, which is also what a terminal that cannot report focus leaves
807 // behind — and that silence is the direction this is allowed to fail in.
808 chrome.announce("scanned", Duration::from_secs(60)).unwrap();
809 assert_eq!(written(&chrome), "");
810
811 chrome.focused(false);
812 chrome.announce("scanned", Duration::from_secs(60)).unwrap();
813 assert!(written(&chrome).contains("\x1b]9;pristine: scanned\x07"));
814
815 chrome.focused(true);
816 let before = written(&chrome).len();
817 chrome.announce("more", Duration::from_secs(60)).unwrap();
818 assert_eq!(written(&chrome).len(), before, "notified after coming back");
819 }
820
821 #[test]
822 fn the_other_spelling_of_a_notification() {
823 let mut chrome = chrome(Decor {
824 notify: Some(Notify::Osc777),
825 ..everything()
826 });
827 chrome.focused(false);
828 chrome
829 .announce("freed 2.0 KiB", Duration::from_secs(60))
830 .unwrap();
831
832 assert_eq!(
833 written(&chrome),
834 "\x1b]777;notify;pristine;freed 2.0 KiB\x07"
835 );
836 }
837
838 #[test]
839 fn focus_reporting_is_only_asked_for_when_it_would_answer_something() {
840 let mut asked = chrome(everything());
841 asked.enter().unwrap();
842 asked.restore().unwrap();
843 assert!(written(&asked).contains("\x1b[?1004h"));
844 assert!(
845 written(&asked).contains("\x1b[?1004l"),
846 "left reporting focus"
847 );
848
849 let mut quiet = chrome(Decor {
850 notify: None,
851 ..everything()
852 });
853 quiet.enter().unwrap();
854 quiet.restore().unwrap();
855 assert!(!written(&quiet).contains("1004"));
856 }
857
858 #[test]
859 fn what_the_view_is_doing_decides_what_the_tab_says() {
860 let mut view = view();
861 assert_eq!(Status::of(&view, 0), Status::Scanning(0));
862
863 view.found(hit("/scan/a/node_modules", Size::Unmeasured, 0));
864 view.found(priced("/scan/b/target", 2048));
865 // Synced first, exactly as the loop does before it asks: the rolled-up numbers are
866 // recomputed once per frame, so reading them without one is reading last frame's.
867 view.sync();
868 // One of two priced, while the walk is still running.
869 assert_eq!(Status::of(&view, 0), Status::Pricing(50));
870
871 view.priced(
872 std::path::Path::new("/scan/a/node_modules"),
873 Size::Measured(1024),
874 );
875 view.sync();
876 assert_eq!(Status::of(&view, 0), Status::Scanning(3072));
877
878 view.scanned();
879 assert_eq!(Status::of(&view, 0), Status::Idle(3072));
880 // A session that removed something says what it got back rather than what is left,
881 // because that is the number the reader went away to wait for.
882 assert_eq!(Status::of(&view, 4096), Status::Freed(4096));
883
884 // …until a removal starts, which outranks everything: it is the one thing running, and
885 // unlike the walk it can say how far through it is.
886 view.deleting_for_test();
887 assert_eq!(Status::of(&view, 4096), Status::Deleting(0));
888 }
889
890 #[test]
891 fn a_removal_reports_where_it_has_got_to_rather_than_only_that_it_is_running() {
892 let mut view = view();
893 view.found(priced("/scan/a/node_modules", 1024));
894 view.found(priced("/scan/b/node_modules", 1024));
895 view.found(priced("/scan/c/node_modules", 1024));
896 view.found(priced("/scan/d/node_modules", 1024));
897 view.scanned();
898 view.asking(
899 &["a", "b", "c", "d"]
900 .iter()
901 .map(|name| {
902 Planned::at(
903 PathBuf::from(format!("/scan/{name}/node_modules")),
904 Size::Measured(1024),
905 )
906 })
907 .collect::<Vec<_>>(),
908 &[],
909 );
910 view.apply(Action::Highlight(Turn::Next));
911 view.apply(Action::Answer);
912
913 // A bar rather than the indeterminate throbber the walk gets: the denominator was
914 // fixed by the confirmation, so every target that comes back moves it by a knowable
915 // amount. This is the whole difference between "something is happening" and "you are
916 // half way".
917 assert_eq!(Status::of(&view, 0), Status::Deleting(0));
918 assert_eq!(Status::of(&view, 0).bar().code(), (1, 0));
919
920 view.removed(std::path::Path::new("/scan/a/node_modules"), 1024, true);
921 view.swept(std::path::Path::new("/scan/a/node_modules"));
922 assert_eq!(Status::of(&view, 0), Status::Deleting(25));
923 // A target the sweep could not finish is still one it is no longer working on, and so
924 // is a target it could not touch at all: the bar says where the deleter is, not how
925 // much of the batch worked.
926 view.removed(std::path::Path::new("/scan/b/node_modules"), 512, false);
927 view.swept(std::path::Path::new("/scan/b/node_modules"));
928 view.swept(std::path::Path::new("/scan/c/node_modules"));
929 assert_eq!(Status::of(&view, 0), Status::Deleting(75));
930 assert_eq!(Status::of(&view, 0).bar().code(), (1, 75));
931
932 // And the batch reporting takes the bar down.
933 view.deleted(crate::tui::state::Notice::standing("freed 1.5 KiB"), 1536);
934 assert_eq!(Status::of(&view, 1536), Status::Freed(1536));
935 assert_eq!(Status::of(&view, 1536).bar().code(), (0, 0));
936 }
937
938 #[test]
939 fn an_unpriced_scan_is_indeterminate_rather_than_stuck_at_zero() {
940 // `--breakdown-under` leaves most claims unpriced forever, so the percentage is not a
941 // fraction of anything that will complete. It still describes what has been priced.
942 let mut view = view();
943 for n in 0..4 {
944 view.found(hit(
945 &format!("/scan/p{n}/node_modules"),
946 Size::Unmeasured,
947 0,
948 ));
949 }
950 view.sync();
951 assert_eq!(Status::of(&view, 0), Status::Pricing(0));
952 view.scanned();
953 assert_eq!(Status::of(&view, 0), Status::Idle(0));
954 }
955
956 #[test]
957 fn a_dumb_terminal_gets_nothing_and_an_unknown_one_gets_only_what_leaves_nothing_behind() {
958 assert_eq!(Decor::read(&env(&[("TERM", "dumb")])), Decor::silent());
959 assert_eq!(Decor::read(&env(&[])), Decor::silent());
960
961 // A private mode is the only one of the five an unidentified terminal can be sent
962 // safely: anything that parses `CSI` drops it, and it leaves no state behind. A title
963 // would be left standing, the two OSCs can be misread as each other, and an image
964 // sent to a terminal that cannot decode it is a screenful of base64.
965 assert_eq!(
966 Decor::read(&env(&[("TERM", "xterm-256color")])),
967 Decor {
968 sync: true,
969 title: None,
970 progress: false,
971 notify: None,
972 graphics: false,
973 }
974 );
975 }
976
977 #[test]
978 fn a_multiplexer_is_not_the_terminal_named_in_the_environment() {
979 // Inside tmux, `TERM_PROGRAM` names whatever started the server — possibly something
980 // that is no longer running, and certainly not what is parsing these bytes. Taking it
981 // at its word sets a title tmux is then left holding.
982 for term in ["screen-256color", "tmux-256color"] {
983 assert_eq!(
984 Decor::read(&env(&[("TERM", term), ("TERM_PROGRAM", "ghostty")])),
985 Decor {
986 sync: true,
987 ..Decor::silent()
988 },
989 "{term} was taken for the terminal that started it"
990 );
991 }
992 }
993
994 #[test]
995 fn every_terminal_offered_a_title_is_offered_the_way_to_put_it_back() {
996 // Over the shipped table rather than a fixture. The type is what makes this hold — a
997 // title is the push/pop pair, so "sets a title" cannot be spelled without the restore
998 // — and this is the assertion that the table cannot quietly acquire a half of one.
999 for known in super::KNOWN {
1000 if let Some(title) = known.decor.title {
1001 assert!(
1002 !title.push.is_empty() && !title.pop.is_empty(),
1003 "{} sets a title it cannot put back",
1004 known.program
1005 );
1006 }
1007 }
1008 }
1009
1010 #[test]
1011 fn the_terminals_that_are_known_get_what_they_are_known_to_read() {
1012 let ghostty = Decor::read(&env(&[
1013 ("TERM", "xterm-ghostty"),
1014 ("TERM_PROGRAM", "ghostty"),
1015 ]));
1016 assert!(ghostty.progress && ghostty.notify == Some(Notify::Osc9));
1017 assert_eq!(ghostty.title, Some(XTERM_STACK));
1018
1019 // A terminal that sets no `TERM_PROGRAM` is found by the `TERM` that names it and
1020 // nothing else — which `xterm-256color` is not, and is why that is the second key.
1021 let kitty = Decor::read(&env(&[("TERM", "xterm-kitty")]));
1022 assert_eq!(kitty.title, Some(XTERM_STACK));
1023 assert!(!kitty.progress);
1024 assert!(kitty.graphics, "the terminal the protocol is named after");
1025
1026 // …and the one that has inline images of a different spelling gets none, because a
1027 // row here is a claim that the terminal reads *this* sequence.
1028 assert!(
1029 !Decor::read(&env(&[
1030 ("TERM_PROGRAM", "iTerm.app"),
1031 ("TERM", "xterm-256color")
1032 ]))
1033 .graphics
1034 );
1035
1036 let iterm = Decor::read(&env(&[
1037 ("TERM", "xterm-256color"),
1038 ("TERM_PROGRAM", "iTerm.app"),
1039 ]));
1040 assert!(
1041 !iterm.progress,
1042 "a progress report would arrive as a pop-up"
1043 );
1044 assert_eq!(iterm.notify, Some(Notify::Osc9));
1045
1046 // Windows Terminal names itself in a variable of its own rather than in TERM_PROGRAM,
1047 // and is not known to keep a title stack.
1048 let wt = Decor::read(&env(&[("TERM", "xterm-256color"), ("WT_SESSION", "…")]));
1049 assert!(wt.progress);
1050 assert_eq!(wt.notify, None);
1051 assert_eq!(wt.title, None);
1052
1053 let apple = Decor::read(&env(&[
1054 ("TERM", "xterm-256color"),
1055 ("TERM_PROGRAM", "Apple_Terminal"),
1056 ]));
1057 assert!(!apple.progress);
1058 assert_eq!(apple.notify, None);
1059 assert_eq!(apple.title, None);
1060 }
1061
1062 #[test]
1063 fn nothing_interpolated_can_end_the_sequence_it_is_inside() {
1064 assert_eq!(text("node_modules\x07;rm -rf /"), "node_modules;rm -rf /");
1065 assert_eq!(text("a\x1b]0;b"), "a]0;b");
1066 }
1067}