Skip to main content

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}