Skip to main content

putzen_cli/caches/tui/
view.rs

1//! Render the whole screen. Pure function from State → frame buffer.
2
3use ratatui::{
4    buffer::Buffer,
5    layout::{Alignment, Constraint, Direction, Layout, Rect},
6    style::{Modifier, Style},
7    text::{Line, Span},
8    widgets::{
9        Block, Borders, Clear, List, ListItem, Paragraph, Scrollbar, ScrollbarOrientation,
10        ScrollbarState, StatefulWidget, Widget,
11    },
12};
13
14use super::widgets::Theme;
15use super::{Modal, RunOutcome, State};
16use crate::caches::format::{
17    human_age, human_count, human_date, human_int, human_size, human_size_parts, pluralize,
18    tildify, truncate_with_ellipsis,
19};
20
21const THEME: Theme = Theme::GRUVBOX;
22
23/// Activity histogram bucket upper bounds in seconds: <1d, <1w, <1mo,
24/// <3mo, <6mo, <1y, <3y, ≥3y.
25pub(super) const ACTIVITY_BUCKETS: [u64; 8] = [
26    86_400,
27    604_800,
28    2_592_000,
29    7_776_000,
30    15_552_000,
31    31_536_000,
32    94_608_000,
33    u64::MAX,
34];
35/// Spark-bar glyphs from shortest to tallest.
36pub(super) const SPARKS: [&str; 8] = ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"];
37
38pub fn render(state: &mut State, area: Rect, buf: &mut Buffer) {
39    // Body fills almost the whole screen; only the key hints row sits below.
40    // Mark / filter / count state lives in the left pane's bottom border now.
41    let outer = Layout::default()
42        .direction(Direction::Vertical)
43        .constraints([Constraint::Min(5), Constraint::Length(1)])
44        .split(area);
45
46    // Left/right split favours the list (rank table is what the user scans);
47    // the details pane is a sidecar — 30% is enough for title, stats, top files.
48    let body = Layout::default()
49        .direction(Direction::Horizontal)
50        .constraints([Constraint::Ratio(7, 10), Constraint::Ratio(3, 10)])
51        .split(outer[0]);
52
53    render_left(state, body[0], buf);
54    render_right(state, body[1], buf);
55    render_footer_keys(&*state, outer[1], buf);
56    render_modal(&*state, area, buf);
57    render_active_mark_modal(&*state, area, buf);
58    render_loading_modal(&*state, area, buf);
59    if let Some(ov) = state.overlay.as_ref() {
60        draw_result(&ov.outcome, area, buf);
61    }
62}
63
64fn render_loading_modal(state: &State, area: Rect, buf: &mut Buffer) {
65    let Some(l) = state.loading.as_ref() else {
66        return;
67    };
68    let body_style = THEME.modal_body_style();
69    let block_style = THEME.modal_block_style();
70
71    let spinner = format!("{}  {}", l.glyph(), l.label);
72    let detail_line = match l.folders {
73        Some(n) => format!(
74            "scanned {} {}",
75            human_int(n as u64),
76            pluralize(n as u64, "folder", "folders")
77        ),
78        None => {
79            let s = l.started.elapsed().as_secs();
80            if s > 0 {
81                format!("{s}s elapsed")
82            } else {
83                String::new()
84            }
85        }
86    };
87
88    let mut lines = vec![
89        Line::from(Span::raw("")),
90        Line::from(Span::styled(
91            spinner,
92            body_style.add_modifier(Modifier::BOLD),
93        )),
94    ];
95    if !detail_line.is_empty() {
96        lines.push(Line::from(Span::raw("")));
97        lines.push(Line::from(Span::styled(detail_line, THEME.dim_style())));
98    }
99
100    let h = (lines.len() as u16 + 2).min(area.height).max(5);
101    let w = area.width.min(60);
102    let x = area.x + (area.width.saturating_sub(w)) / 2;
103    let y = area.y + (area.height.saturating_sub(h)) / 2;
104    let modal = Rect {
105        x,
106        y,
107        width: w,
108        height: h,
109    };
110
111    Clear.render(modal, buf);
112    Paragraph::new(lines)
113        .style(body_style)
114        .alignment(Alignment::Center)
115        .block(
116            Block::default()
117                .borders(Borders::ALL)
118                .border_style(block_style)
119                .style(body_style)
120                .title(Span::styled(
121                    " Loading ",
122                    block_style.add_modifier(Modifier::BOLD),
123                ))
124                .title_alignment(Alignment::Center),
125        )
126        .render(modal, buf);
127}
128
129/// Render a centred result modal that summarises a delete pass. Shown for
130/// ~2 seconds inside the TUI before tearing down the alternate screen.
131pub fn draw_result(outcome: &RunOutcome, area: Rect, buf: &mut Buffer) {
132    let w = area.width.min(60);
133    let h = area.height.min(7);
134    let x = area.x + (area.width.saturating_sub(w)) / 2;
135    let y = area.y + (area.height.saturating_sub(h)) / 2;
136    let modal = Rect {
137        x,
138        y,
139        width: w,
140        height: h,
141    };
142
143    let body_style = THEME.modal_body_style();
144    let block_style = THEME.modal_block_style();
145
146    let title_text = if outcome.dry_run {
147        " Dry run "
148    } else {
149        " Done "
150    };
151    let failed_suffix = if outcome.failed > 0 {
152        format!(" ({} failed)", outcome.failed)
153    } else {
154        String::new()
155    };
156    let noun = pluralize(outcome.deleted as u64, "folder", "folders");
157    let line = if outcome.dry_run {
158        format!(
159            "Would free {} across {} {noun}{failed_suffix}",
160            human_size(outcome.freed),
161            outcome.deleted,
162        )
163    } else {
164        format!(
165            "Freed {} across {} {noun}{failed_suffix}",
166            human_size(outcome.freed),
167            outcome.deleted,
168        )
169    };
170
171    Clear.render(modal, buf);
172    Paragraph::new(vec![
173        Line::from(Span::raw("")),
174        Line::from(Span::styled(line, body_style.add_modifier(Modifier::BOLD))),
175    ])
176    .style(body_style)
177    .alignment(Alignment::Center)
178    .block(
179        Block::default()
180            .borders(Borders::ALL)
181            .border_style(block_style)
182            .style(body_style)
183            .title(Span::styled(
184                title_text,
185                block_style.add_modifier(Modifier::BOLD),
186            ))
187            .title_alignment(Alignment::Center),
188    )
189    .render(modal, buf);
190}
191
192fn render_active_mark_modal(state: &State, area: Rect, buf: &mut Buffer) {
193    if !matches!(state.modal, Modal::ActiveMark(_)) {
194        return;
195    }
196    let w = area.width.min(64);
197    let h = area.height.min(9);
198    let x = area.x + (area.width.saturating_sub(w)) / 2;
199    let y = area.y + (area.height.saturating_sub(h)) / 2;
200    let modal = Rect {
201        x,
202        y,
203        width: w,
204        height: h,
205    };
206
207    let body_style = THEME.modal_body_style();
208    let block_style = THEME.modal_block_style();
209    let key_style = THEME.gutter_active_style();
210
211    let n_days = state.floor.floor.as_secs() / 86_400;
212
213    let lines = vec![
214        Line::from(Span::raw("")),
215        Line::from(Span::styled(
216            format!("The cache folder age is < {n_days} days,"),
217            body_style,
218        )),
219        Line::from(Span::styled(
220            "so that cache seems to be active.",
221            body_style,
222        )),
223        Line::from(Span::styled(
224            "Sure marking it for deletion?",
225            body_style.add_modifier(Modifier::BOLD),
226        )),
227        Line::from(Span::raw("")),
228        Line::from(vec![
229            Span::styled("  [", body_style),
230            Span::styled("y", key_style),
231            Span::styled("] yes   ", body_style),
232            Span::styled("[", body_style),
233            Span::styled("N", key_style),
234            Span::styled("] cancel", body_style),
235        ]),
236    ];
237
238    Clear.render(modal, buf);
239    Paragraph::new(lines)
240        .style(body_style)
241        .alignment(Alignment::Center)
242        .block(
243            Block::default()
244                .borders(Borders::ALL)
245                .border_style(block_style)
246                .style(body_style)
247                .title(Span::styled(
248                    " Confirm marking active cache ",
249                    block_style.add_modifier(Modifier::BOLD),
250                ))
251                .title_alignment(Alignment::Center),
252        )
253        .render(modal, buf);
254}
255
256fn render_modal(state: &State, area: Rect, buf: &mut Buffer) {
257    if !matches!(state.modal, Modal::DeleteConfirm) {
258        return;
259    }
260
261    let body_style = THEME.modal_body_style();
262    let dim_style = THEME.dim_style();
263    let block_style = THEME.modal_block_style();
264    let key_style = THEME.gutter_active_style();
265
266    let total: u64 = state
267        .marks
268        .marked
269        .iter()
270        .filter_map(|&i| state.all.get(i).map(|c| c.size_bytes))
271        .sum();
272    let count = state.marks.count();
273    const MAX_LIST: usize = 3;
274
275    let mut lines: Vec<Line> = vec![Line::from(Span::raw(""))];
276
277    if count <= MAX_LIST {
278        // Few enough: list each cache and a Total row.
279        for &i in state.marks.marked.iter() {
280            if let Some(c) = state.all.get(i) {
281                lines.push(Line::from(vec![
282                    Span::styled(format!("{}  ", c.label), body_style),
283                    Span::styled(human_size(c.size_bytes), dim_style),
284                ]));
285            }
286        }
287        lines.push(Line::from(Span::raw("")));
288        lines.push(Line::from(vec![
289            Span::styled("Total: ", body_style),
290            Span::styled(human_size(total), body_style.add_modifier(Modifier::BOLD)),
291        ]));
292    } else {
293        // Too many to fit; summarise.
294        lines.push(Line::from(vec![
295            Span::styled(
296                format!(
297                    "{count} {} · ",
298                    pluralize(count as u64, "folder", "folders")
299                ),
300                body_style,
301            ),
302            Span::styled(human_size(total), body_style.add_modifier(Modifier::BOLD)),
303        ]));
304    }
305
306    lines.push(Line::from(Span::raw("")));
307    // Enter (and `y`) confirms — Y is uppercase to signal the default.
308    lines.push(Line::from(vec![
309        Span::styled("[", body_style),
310        Span::styled("Y", key_style),
311        Span::styled("] yes   ", body_style),
312        Span::styled("[", body_style),
313        Span::styled("n", key_style),
314        Span::styled("] cancel", body_style),
315    ]));
316    if state.dry_run {
317        lines.push(Line::from(Span::styled(
318            "no files will be touched",
319            dim_style,
320        )));
321    }
322
323    // Modal sized to fit the chosen content.
324    let h = (lines.len() as u16 + 2).min(area.height).max(5);
325    let w = area.width.min(60);
326    let x = area.x + (area.width.saturating_sub(w)) / 2;
327    let y = area.y + (area.height.saturating_sub(h)) / 2;
328    let modal = Rect {
329        x,
330        y,
331        width: w,
332        height: h,
333    };
334
335    let noun = pluralize(count as u64, "folder", "folders");
336    let title_text = if state.dry_run {
337        format!(" Delete {count} {noun}? (dry run) ")
338    } else {
339        format!(" Delete {count} {noun}? ")
340    };
341
342    Clear.render(modal, buf);
343    Paragraph::new(lines)
344        .style(body_style)
345        .alignment(Alignment::Center)
346        .block(
347            Block::default()
348                .borders(Borders::ALL)
349                .border_style(block_style)
350                .style(body_style)
351                .title(Span::styled(
352                    title_text,
353                    block_style.add_modifier(Modifier::BOLD),
354                ))
355                .title_alignment(Alignment::Center),
356        )
357        .render(modal, buf);
358}
359
360/// Width of the right-side padding inside the left panel block, kept here
361/// so `col_widths` and the actual `Block::padding(...)` stay in sync.
362pub(super) const LEFT_PANEL_RIGHT_PAD: u16 = 1;
363
364pub(super) fn col_widths(area_width: u16) -> (usize, usize, usize, usize) {
365    // Inner = outer area - block borders (2) - right padding so AGE doesn't
366    // get clipped against the scrollbar overlay.
367    let inner = (area_width as usize).saturating_sub(2 + LEFT_PANEL_RIGHT_PAD as usize);
368    // Budget for content cells:
369    //   gutter (2) + name + " " + score + " " + size + " " + age = inner
370    // So three inter-column separators (3 cells) come off the top too.
371    let budget = inner.saturating_sub(2 + 3);
372
373    // SIZE: number sub-column (4) + space + unit sub-column (3) = 8 cells so
374    // values stack with both number and unit right-aligned.
375    // AGE: pinned minimum, real values need 5.
376    let size_w = 8;
377    let age_w = 5;
378
379    // Priority for the remaining budget:
380    //   1. Score aims for 30 cells (gives the bar enough resolution to
381    //      read like a real heatmap).  Score doesn't grow beyond that —
382    //      extra cells go to the name column where they actually help
383    //      readability.
384    //   2. Score yields cells to name when there isn't room for 30 + name_floor.
385    //   3. Name takes everything else; labels that still don't fit get
386    //      truncated with an ellipsis by the renderer.
387    const SCORE_TARGET: usize = 30;
388    const NAME_FLOOR: usize = 8;
389    const SCORE_FLOOR: usize = 4;
390    let after_pinned = budget.saturating_sub(size_w + age_w);
391    let max_score = after_pinned.saturating_sub(NAME_FLOOR);
392    let score = SCORE_TARGET
393        .min(max_score)
394        .max(SCORE_FLOOR.min(after_pinned));
395    let name = after_pinned.saturating_sub(score).max(1);
396    (name, score, size_w, age_w)
397}
398
399fn render_left(state: &mut State, area: Rect, buf: &mut Buffer) {
400    let (name_w, score_w, size_w, age_w) = col_widths(area.width);
401
402    let indices = state.sorted_indices();
403    let header_style = THEME.header_style();
404    let body_style = THEME.body_style();
405    let active_style = THEME.gutter_active_style();
406    let marked_style = THEME.gutter_marked_style();
407
408    let max_score = indices
409        .iter()
410        .map(|&i| state.all[i].score(state.now))
411        .fold(0f64, f64::max)
412        .max(1e-9);
413
414    let header_line = Line::styled(
415        format!(
416            "  {:<nw$} {:<sw$} {:>zw$} {:>aw$}",
417            "NAME",
418            "SCORE",
419            "SIZE",
420            "AGE",
421            nw = name_w,
422            sw = score_w,
423            zw = size_w,
424            aw = age_w,
425        ),
426        header_style,
427    );
428
429    let items: Vec<ListItem> = indices
430        .iter()
431        .enumerate()
432        .map(|(visible_row, &idx)| {
433            let c = &state.all[idx];
434            let active = visible_row == state.cursor;
435            let marked = state.marks.is_marked(idx);
436            // When a row is BOTH active and marked, paint the `●` in the active
437            // colour so the cursor stays visible instead of being hidden under
438            // the marked-orange dot.
439            let gutter = match (marked, active) {
440                (true, true) => Span::styled("● ", active_style),
441                (true, false) => Span::styled("● ", marked_style),
442                (false, true) => Span::styled("┃ ", active_style),
443                (false, false) => Span::raw("  "),
444            };
445            let age = c
446                .age(state.now)
447                .map(human_age)
448                .unwrap_or_else(|| "—".into());
449            // Split into number + unit so both right-align in their own
450            // sub-columns: "  28 KiB" / " 713   B" instead of " 28 KiB" /
451            // "  713 B" (which only right-anchored the unit's tail).
452            let (size_num, size_unit) = human_size_parts(c.size_bytes);
453            let size_num_w = 4;
454            let size_unit_w = 3;
455            let size_str = format!(
456                "{:>nw$} {:>uw$}",
457                size_num,
458                size_unit,
459                nw = size_num_w,
460                uw = size_unit_w
461            );
462
463            // Right-anchor size + age. If their actual width exceeds the planned
464            // column widths, grow LEFT by eating into the score bar's width — so
465            // age never gets pushed off the right edge of the pane.
466            let size_extra = size_str.chars().count().saturating_sub(size_w);
467            let age_extra = age.chars().count().saturating_sub(age_w);
468            let score_eff = score_w.saturating_sub(size_extra + age_extra).max(1);
469
470            let score = c.score(state.now);
471            let cells = if c.newest_mtime.is_none() || score <= 0.0 {
472                0
473            } else {
474                // Any positive score earns at least one cell; tiny rows should
475                // not look indistinguishable from empty / null-mtime ones.
476                let raw = ((score / max_score) * score_eff as f64).round() as usize;
477                raw.max(1).min(score_eff)
478            };
479            let bar = "█".repeat(cells);
480            // Smooth gradient ok → warm → hot keyed by score / max_score.
481            // A row's bar colour is its rank among the visible set; the
482            // active-cache cue (recent mtime) lives in the gutter glyph and
483            // the active-mark confirm modal, not in the bar.
484            let bar_t = if cells == 0 { 0.0 } else { score / max_score };
485            let bar_style = Style::default().fg(THEME.score_color(bar_t));
486
487            // On the selected row, tint name/size/age yellow but keep the
488            // bar at its gradient colour. The list-wide highlight only paints
489            // bg, so per-span fg wins and the bar stays on the heat-map.
490            let text_style = if active {
491                Style::default().fg(THEME.gutter_active)
492            } else {
493                body_style
494            };
495
496            // Truncate labels that don't fit the name column so the bar /
497            // size / age columns can't get shoved off the right edge.
498            let label = truncate_with_ellipsis(&c.label, name_w);
499            ListItem::new(Line::from(vec![
500                gutter,
501                Span::styled(format!("{label:<nw$} ", nw = name_w), text_style),
502                Span::styled(format!("{:<sw$} ", bar, sw = score_eff), bar_style),
503                // Right-align size + age. Shorts get left-padded to their min
504                // width; longs render unpadded but `score_eff` was shrunk above
505                // to keep the line aligned to the right edge.
506                Span::styled(format!("{:>zw$} ", size_str, zw = size_w), text_style),
507                Span::styled(format!("{:>aw$}", age, aw = age_w), text_style),
508            ]))
509        })
510        .collect();
511
512    let title = if state.stack_labels.is_empty() {
513        " putzen caches — ranked ".to_string()
514    } else {
515        format!(
516            " putzen caches — ranked — {} ",
517            state.stack_labels.join(" > ")
518        )
519    };
520
521    // Draw the block + borders + title first, then split the inner area into
522    // a 1-row header and the scrollable list body. When the right pane has
523    // focus, paint the left border in the active gutter colour to make the
524    // focus visible at a glance.
525    let border_style = if !state.focus_right {
526        Style::default().fg(THEME.gutter_active).bg(THEME.bg)
527    } else {
528        THEME.block_style()
529    };
530    // 1-cell right padding so list content doesn't touch the scrollbar
531    // overlaid on the right border. Mirrored in `col_widths` via
532    // `LEFT_PANEL_RIGHT_PAD` so AGE stays inside the rendered area.
533    // Bottom-border status: marks on the left (bold marked-orange, hidden when
534    // empty), visible/total + active filter on the right (dim).
535    let dim_style = THEME.dim_style();
536    let marked_style = THEME.gutter_marked_style().add_modifier(Modifier::BOLD);
537    let marks_count = state.marks.count();
538    let mut bottom_titles: Vec<Line> = Vec::new();
539    if marks_count > 0 {
540        let total: u64 = state
541            .marks
542            .marked
543            .iter()
544            .filter_map(|&i| state.all.get(i).map(|c| c.size_bytes))
545            .sum();
546        bottom_titles.push(
547            Line::from(Span::styled(
548                format!(" {marks_count} marked · {} ready ", human_size(total)),
549                marked_style,
550            ))
551            .left_aligned(),
552        );
553    }
554    let total_caches = state.all.len();
555    let visible_count = indices.len();
556    let mut right_text = if visible_count == total_caches {
557        format!(
558            " {total_caches} {} ",
559            pluralize(total_caches as u64, "folder", "folders")
560        )
561    } else {
562        format!(" {visible_count}/{total_caches} folders ")
563    };
564    if let Some(f) = state.filter.as_ref() {
565        if !f.input.is_empty() {
566            right_text = format!(" {} · filter: {} ", right_text.trim(), f.input);
567        }
568    }
569    bottom_titles.push(Line::from(Span::styled(right_text, dim_style)).right_aligned());
570
571    let mut block = Block::default()
572        .borders(Borders::ALL)
573        .border_style(border_style)
574        .style(body_style)
575        .padding(ratatui::widgets::Padding::right(LEFT_PANEL_RIGHT_PAD))
576        .title(Span::styled(title, THEME.title_style()));
577    for t in bottom_titles {
578        block = block.title_bottom(t);
579    }
580    let inner = block.inner(area);
581    block.render(area, buf);
582
583    // Reserve a 1-row strip at the bottom for the filter, if any.
584    let filter_present = state.filter.is_some();
585    let constraints: Vec<Constraint> = if filter_present {
586        vec![
587            Constraint::Length(1),
588            Constraint::Min(1),
589            Constraint::Length(1),
590        ]
591    } else {
592        vec![Constraint::Length(1), Constraint::Min(1)]
593    };
594    let chunks = Layout::default()
595        .direction(Direction::Vertical)
596        .constraints(constraints)
597        .split(inner);
598
599    Paragraph::new(header_line)
600        .style(body_style)
601        .render(chunks[0], buf);
602
603    if filter_present {
604        render_filter_strip(state, chunks[2], buf);
605    }
606
607    // Build a local ListState so ratatui auto-scrolls to keep the cursor visible.
608    let mut left_ls = ratatui::widgets::ListState::default();
609    left_ls.select(Some(state.cursor));
610
611    // Subtle bg + yellow-ish fg on the active row so it pops across every
612    // column. Per-span colours that already specify an fg (the score bar's
613    // hot/warm/ok tier, the gutter, etc.) keep their own colour.
614    let visible_len = state.sorted_indices().len();
615
616    // bg-only highlight: keeps each span's explicit fg (notably the score
617    // bar's hot/ok tier) intact, while still painting the active row's
618    // background subtly so the cursor is readable across all columns.
619    let list = List::new(items).highlight_style(Style::default().bg(THEME.bg_sel));
620    StatefulWidget::render(list, chunks[1], buf, &mut left_ls);
621
622    // Overlay a vertical scrollbar ON the right border of the block, but
623    // only spanning the LIST body — not the column header above nor the
624    // bottom border (or filter strip) below. The border line itself acts
625    // as the track; the thumb overdraws it with a solid block.
626    if visible_len > chunks[1].height as usize {
627        let mut sb_state = ScrollbarState::new(visible_len)
628            .position(state.cursor.min(visible_len.saturating_sub(1)));
629        let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
630            .begin_symbol(None)
631            .end_symbol(None)
632            .track_symbol(None)
633            .thumb_symbol("█")
634            .thumb_style(Style::default().fg(THEME.gutter_active));
635        let sb_area = Rect {
636            x: area.x,
637            y: chunks[1].y,
638            width: area.width,
639            height: chunks[1].height,
640        };
641        StatefulWidget::render(scrollbar, sb_area, buf, &mut sb_state);
642    }
643}
644
645/// Compute two rows of spark glyphs for the temporal distribution of file
646/// mtimes in `c.top_files`. Each of the 8 buckets occupies a 4-cell slot
647/// (spark + 3 spaces) so the bar aligns under the axis labels with breathing
648/// room. The bar is rendered across two rows, giving it 16 visual levels.
649fn activity_sparkline_rows(
650    c: &crate::caches::model::Cache,
651    now: std::time::SystemTime,
652) -> [Vec<Span<'static>>; 2] {
653    let mut counts = [0u32; 8];
654    for tf in &c.top_files {
655        let Some(m) = tf.mtime else { continue };
656        let age = now.duration_since(m).unwrap_or_default().as_secs();
657        for (i, &upper) in ACTIVITY_BUCKETS.iter().enumerate() {
658            if age < upper {
659                counts[i] += 1;
660                break;
661            }
662        }
663    }
664    // Reverse so the axis reads left=old → right=recent.
665    counts.reverse();
666    let max = counts.iter().copied().max().unwrap_or(0);
667    let bar_style = Style::default().fg(THEME.hot);
668    let dim = THEME.dim_style();
669    if max == 0 {
670        return [vec![], vec![Span::styled("no mtime data", dim)]];
671    }
672    let mut top: Vec<Span<'static>> = Vec::with_capacity(16);
673    let mut bot: Vec<Span<'static>> = Vec::with_capacity(16);
674    for &n in &counts {
675        // Map count onto a 0..=16 height (two rows × 8 partial levels).
676        let h = ((n as u64 * 16 / max as u64) as usize).min(16);
677        let (top_glyph, bot_glyph) = if h == 0 {
678            (" ", " ")
679        } else if h <= 8 {
680            (" ", SPARKS[h - 1])
681        } else {
682            (SPARKS[h - 9], SPARKS[7])
683        };
684        top.push(Span::styled(top_glyph, bar_style));
685        top.push(Span::raw("   "));
686        bot.push(Span::styled(bot_glyph, bar_style));
687        bot.push(Span::raw("   "));
688    }
689    [top, bot]
690}
691
692fn render_right(state: &mut State, area: Rect, buf: &mut Buffer) {
693    let indices = state.sorted_indices();
694    let body_style = THEME.body_style();
695    let dim_style = THEME.dim_style();
696    let header_style = THEME.header_style();
697
698    // Draw the bordered block first; we render header + list inside.
699    // Padding pulls content one cell off each border for breathing room.
700    // When this pane has focus, paint its border in the active gutter colour
701    // so the user can see where Up/Down will go.
702    let border_style = if state.focus_right {
703        Style::default().fg(THEME.gutter_active).bg(THEME.bg)
704    } else {
705        THEME.block_style()
706    };
707    let block = Block::default()
708        .borders(Borders::ALL)
709        .border_style(border_style)
710        .style(body_style)
711        .padding(ratatui::widgets::Padding::uniform(1))
712        .title(Span::styled(" details ", THEME.title_style()));
713    let inner = block.inner(area);
714    block.render(area, buf);
715
716    let Some(&idx) = indices.get(state.cursor) else {
717        Paragraph::new(Line::from(Span::styled("no folders", dim_style)))
718            .style(body_style)
719            .render(inner, buf);
720        return;
721    };
722
723    let c = &state.all[idx];
724    let age = c
725        .age(state.now)
726        .map(human_age)
727        .unwrap_or_else(|| "—".into());
728    let touched = c.newest_mtime.map(human_date).unwrap_or_else(|| "—".into());
729
730    let home = std::env::var_os("HOME").map(std::path::PathBuf::from);
731    let path_display = tildify(&c.path, home.as_deref());
732    let mut header_lines = vec![
733        Line::from(Span::styled(c.label.clone(), THEME.title_style())),
734        Line::from(Span::styled(path_display, dim_style)),
735        Line::from(Span::raw("")),
736        Line::from(vec![
737            Span::styled("Size         ", dim_style),
738            Span::styled(human_size(c.size_bytes), body_style),
739        ]),
740        Line::from(vec![
741            Span::styled("Age          ", dim_style),
742            Span::styled(age, body_style),
743        ]),
744        Line::from(vec![
745            Span::styled("Score        ", dim_style),
746            Span::styled(human_count(c.score(state.now)), body_style),
747        ]),
748        Line::from(vec![
749            Span::styled("Files        ", dim_style),
750            Span::styled(human_int(c.file_count), body_style),
751        ]),
752        Line::from(vec![
753            Span::styled("Dirs         ", dim_style),
754            Span::styled(human_int(c.dir_count), body_style),
755        ]),
756        Line::from(vec![
757            Span::styled("Last touched ", dim_style),
758            Span::styled(touched, body_style),
759        ]),
760    ];
761
762    if c.unreadable > 0 {
763        header_lines.push(Line::from(Span::styled(
764            format!(
765                "partial: {} {} unreadable",
766                c.unreadable,
767                pluralize(c.unreadable, "entry", "entries")
768            ),
769            dim_style,
770        )));
771    }
772
773    header_lines.push(Line::from(Span::raw("")));
774    header_lines.push(Line::from(Span::styled("Activity", header_style)));
775    let [top_row, bot_row] = activity_sparkline_rows(c, state.now);
776    if !top_row.is_empty() {
777        header_lines.push(Line::from(top_row));
778    }
779    header_lines.push(Line::from(bot_row));
780    // Axis labels: 4-cell-wide buckets, oldest on the left, most-recent on
781    // the right. Matches the reversed bar above. Leftmost slot is the
782    // open-ended ≥3y bucket, written `3y+`.
783    //   `3y+ 3y  1y  6mo 3mo 1mo 1w  1d  `
784    header_lines.push(Line::from(Span::styled(
785        "3y+ 3y  1y  6mo 3mo 1mo 1w  1d  ",
786        dim_style,
787    )));
788
789    header_lines.push(Line::from(Span::raw("")));
790    header_lines.push(Line::from(Span::styled("Files (by size)", header_style)));
791
792    let header_h = header_lines.len() as u16;
793
794    // Split inner area: header on top, files list below.
795    let chunks = Layout::default()
796        .direction(Direction::Vertical)
797        .constraints([Constraint::Length(header_h), Constraint::Min(0)])
798        .split(inner);
799
800    Paragraph::new(header_lines)
801        .style(body_style)
802        .render(chunks[0], buf);
803
804    // Right-align size in a 9-char column; truncate name to fit the rest.
805    let inner_w = inner.width as usize;
806    let size_w = 9usize;
807    let name_w = inner_w.saturating_sub(size_w + 1).max(8);
808
809    let items: Vec<ListItem> = c
810        .top_files
811        .iter()
812        .map(|tf| {
813            let mut name = tf.name.clone();
814            if name.chars().count() > name_w {
815                let truncated: String = name.chars().take(name_w.saturating_sub(1)).collect();
816                name = format!("{truncated}…");
817            }
818            let size = human_size(tf.size_bytes);
819            ListItem::new(Line::from(vec![
820                Span::styled(format!("{:<nw$} ", name, nw = name_w), body_style),
821                Span::styled(format!("{:>sw$}", size, sw = size_w), dim_style),
822            ]))
823        })
824        .collect();
825
826    // When this pane has focus, show a yellow selection indicator + subtle
827    // bg highlight on the active file so the user can see where Up/Down
828    // points. When unfocused, no highlight.
829    let (highlight_style, highlight_symbol) = if state.focus_right {
830        (
831            Style::default().fg(THEME.gutter_active).bg(THEME.bg_sel),
832            "┃ ",
833        )
834    } else {
835        (Style::default(), "  ")
836    };
837    let list = List::new(items)
838        .highlight_style(highlight_style)
839        .highlight_symbol(highlight_symbol);
840    let mut right_ls = ratatui::widgets::ListState::default();
841    right_ls.select(Some(state.files_cursor));
842    StatefulWidget::render(list, chunks[1], buf, &mut right_ls);
843}
844
845fn render_filter_strip(state: &State, area: Rect, buf: &mut Buffer) {
846    let Some(f) = state.filter.as_ref() else {
847        return;
848    };
849    let dim = THEME.dim_style();
850    let active = Style::default().fg(THEME.gutter_active);
851    let body = THEME.body_style();
852
853    let spans: Vec<Span> = if matches!(state.modal, Modal::FilterEdit) {
854        vec![
855            Span::styled("/", active),
856            Span::styled(f.input.clone(), body),
857            Span::styled("█  ", active),
858            Span::styled("(Enter to apply, Esc to cancel)", dim),
859        ]
860    } else {
861        let n = state.sorted_indices().len();
862        vec![
863            Span::styled("/", active),
864            Span::styled(f.input.clone(), body),
865            Span::styled("   ", body),
866            Span::styled(format!("({n} matches  ·  "), dim),
867            Span::styled("[*]", active),
868            Span::styled(" mark all  ·  ", dim),
869            Span::styled("[/]", active),
870            Span::styled(" edit)", dim),
871        ]
872    };
873    Paragraph::new(Line::from(spans)).render(area, buf);
874}
875
876fn render_footer_keys(state: &State, area: Rect, buf: &mut Buffer) {
877    let dim = THEME.dim_style();
878    let editing = matches!(state.modal, Modal::FilterEdit);
879    let text = if editing {
880        "[Enter] apply filter  [Esc] cancel  [Backspace] erase"
881    } else if state.focus_right {
882        "[↑↓/jk] scroll files  [Tab/Esc] back to list  [q] quit"
883    } else if state.filter.is_some() {
884        "[↑↓/jk] move  [/] edit filter  [*] mark all  [Space] mark  [m] mark-to  [s] sort  [d] delete  [q] quit"
885    } else {
886        "[↑↓/jk] move  [Tab] focus  [/] filter  [Space] mark  [m] mark-to  [s] sort  [→/l/Enter] drill  [←/h/Esc] back  [d] delete  [q] quit"
887    };
888    Paragraph::new(Line::from(Span::styled(text, dim)))
889        .style(Style::default())
890        .render(area, buf);
891}
892
893#[cfg(test)]
894mod tests {
895    use super::*;
896    use crate::caches::model::{Cache, FloorPolicy, MarkSet, Sort};
897    use crate::caches::tui::State;
898    use ratatui::backend::TestBackend;
899    use ratatui::Terminal;
900    use std::path::PathBuf;
901    use std::time::{Duration, SystemTime};
902
903    fn fixture() -> State {
904        let now = SystemTime::UNIX_EPOCH + Duration::from_secs(100 * 86_400);
905        State {
906            now,
907            all: vec![
908                Cache {
909                    label: "alpha".into(),
910                    path: PathBuf::from("/x/alpha"),
911                    size_bytes: 2_000_000_000,
912                    newest_mtime: Some(SystemTime::UNIX_EPOCH + Duration::from_secs(0)),
913                    file_count: 10,
914                    dir_count: 0,
915                    top_files: Vec::new(),
916                    unreadable: 0,
917                },
918                Cache {
919                    label: "beta".into(),
920                    path: PathBuf::from("/x/beta"),
921                    size_bytes: 50_000_000,
922                    newest_mtime: Some(SystemTime::UNIX_EPOCH + Duration::from_secs(90 * 86_400)),
923                    file_count: 4,
924                    dir_count: 1,
925                    top_files: Vec::new(),
926                    unreadable: 0,
927                },
928            ],
929            sort: Sort::Score,
930            marks: MarkSet::default(),
931            cursor: 0,
932            files_cursor: 0,
933            floor: FloorPolicy {
934                floor: Duration::from_secs(7 * 86_400),
935            },
936            focus_right: false,
937            stack: Vec::new(),
938            stack_labels: Vec::new(),
939            quit: false,
940            modal: crate::caches::tui::Modal::None,
941            dry_run: false,
942            yes_mode: false,
943            total_freed: 0,
944            filter: None,
945            loading: None,
946            overlay: None,
947            level_dirty: false,
948            drill_paths: Vec::new(),
949            cursor_stack: Vec::new(),
950        }
951    }
952
953    fn buffer_to_string(buf: &Buffer) -> String {
954        let mut out = String::new();
955        for y in 0..buf.area().height {
956            for x in 0..buf.area().width {
957                out.push_str(buf[(x, y)].symbol());
958            }
959            out.push('\n');
960        }
961        out
962    }
963
964    #[test]
965    fn col_widths_typical_pane_gives_name_the_slack() {
966        // 100-col terminal at the 70/30 split → left pane ≈ 70 cols.
967        // budget = 70 - 2 (borders) - 1 (right pad) - 2 (gutter) - 3 (separators) = 62
968        // size=8, age=5, score caps at 30 → name = 62-8-5-30 = 19.
969        let (name, score, size, age) = col_widths(70);
970        assert_eq!(size, 8);
971        assert_eq!(age, 5);
972        assert_eq!(score, 30, "score caps at its target on wide panes");
973        assert_eq!(name, 19, "name absorbs everything score doesn't take");
974        assert_eq!(name + score + size + age, 62);
975    }
976
977    #[test]
978    fn col_widths_narrow_pane_shrinks_score_to_protect_name() {
979        // A pane so narrow that 10 + 8 doesn't fit beside size + age:
980        // budget = 27 - 2 - 1 - 2 - 3 = 19 → after pinned (13) = 6.
981        // Score capped at 10 but max_score = 6-8 saturates to 0, so score
982        // floors at 4 and name gets whatever remains (= 2). Labels longer
983        // than 2 chars get truncated by render_left, not by col_widths.
984        let (name, score, _size, _age) = col_widths(27);
985        assert_eq!(score, 4, "score yields cells until it hits its hard floor");
986        assert!(
987            name >= 1,
988            "name keeps at least one column even on a tiny pane"
989        );
990    }
991
992    #[test]
993    fn col_widths_medium_pane_keeps_score_target() {
994        // Boundary case: name floor + score target = 38, plus pinned 13 = 51
995        // budget → 59 cols total pane.  Anything wider keeps score at 30 and
996        // grows name from there.
997        let (name, score, _, _) = col_widths(59);
998        assert_eq!(score, 30);
999        assert!(name >= 8);
1000    }
1001
1002    #[test]
1003    fn render_shows_both_entries_and_active_gutter() {
1004        let backend = TestBackend::new(80, 20);
1005        let mut term = Terminal::new(backend).unwrap();
1006        let mut state = fixture();
1007        term.draw(|f| render(&mut state, f.area(), f.buffer_mut()))
1008            .unwrap();
1009        let buf = term.backend().buffer().clone();
1010        let dump = buffer_to_string(&buf);
1011        assert!(dump.contains("alpha"), "alpha row missing:\n{}", dump);
1012        assert!(dump.contains("beta"), "beta row missing:\n{}", dump);
1013        assert!(dump.contains("┃ alpha"), "active gutter missing:\n{}", dump);
1014    }
1015
1016    #[test]
1017    fn render_includes_score_bar_for_positive_score() {
1018        let backend = TestBackend::new(120, 20);
1019        let mut term = Terminal::new(backend).unwrap();
1020        let mut state = fixture();
1021        term.draw(|f| render(&mut state, f.area(), f.buffer_mut()))
1022            .unwrap();
1023        let dump = buffer_to_string(term.backend().buffer());
1024        assert!(
1025            dump.contains("█"),
1026            "expected at least one bar cell `█`:\n{}",
1027            dump
1028        );
1029    }
1030
1031    #[test]
1032    fn render_status_reflects_marks() {
1033        let backend = TestBackend::new(80, 20);
1034        let mut term = Terminal::new(backend).unwrap();
1035        let mut state = fixture();
1036        state.marks.toggle(0);
1037        term.draw(|f| render(&mut state, f.area(), f.buffer_mut()))
1038            .unwrap();
1039        let dump = buffer_to_string(term.backend().buffer());
1040        assert!(dump.contains("1 marked"), "status missing:\n{}", dump);
1041        assert!(
1042            dump.contains("ready"),
1043            "marked-size summary missing:\n{}",
1044            dump
1045        );
1046    }
1047
1048    #[test]
1049    fn right_pane_shows_score_row() {
1050        let backend = TestBackend::new(80, 20);
1051        let mut term = Terminal::new(backend).unwrap();
1052        let mut state = fixture();
1053        term.draw(|f| render(&mut state, f.area(), f.buffer_mut()))
1054            .unwrap();
1055        let dump = buffer_to_string(term.backend().buffer());
1056        assert!(dump.contains("Score"), "Score row missing:\n{}", dump);
1057    }
1058
1059    #[test]
1060    fn modal_shows_dry_run_hints() {
1061        let backend = TestBackend::new(80, 20);
1062        let mut term = Terminal::new(backend).unwrap();
1063        let mut state = fixture();
1064        state.marks.toggle(0);
1065        state.modal = crate::caches::tui::Modal::DeleteConfirm;
1066        state.dry_run = true;
1067        term.draw(|f| render(&mut state, f.area(), f.buffer_mut()))
1068            .unwrap();
1069        let dump = buffer_to_string(term.backend().buffer());
1070        assert!(
1071            dump.contains("dry run"),
1072            "dry-run title hint missing:\n{}",
1073            dump
1074        );
1075        assert!(
1076            dump.contains("no files will be touched"),
1077            "dry-run footer missing:\n{}",
1078            dump
1079        );
1080    }
1081
1082    #[test]
1083    fn right_pane_shows_top_files() {
1084        let backend = TestBackend::new(120, 30);
1085        let mut term = Terminal::new(backend).unwrap();
1086        let mut state = fixture();
1087        state.all[0].top_files = vec![
1088            crate::caches::model::TopFile {
1089                name: "blob.bin".into(),
1090                size_bytes: 1_500_000_000,
1091                mtime: None,
1092            },
1093            crate::caches::model::TopFile {
1094                name: "data.tar".into(),
1095                size_bytes: 50_000_000,
1096                mtime: None,
1097            },
1098        ];
1099        term.draw(|f| render(&mut state, f.area(), f.buffer_mut()))
1100            .unwrap();
1101        let dump = buffer_to_string(term.backend().buffer());
1102        assert!(dump.contains("Files (by size)"), "files header missing");
1103        assert!(dump.contains("blob.bin"), "biggest file missing");
1104    }
1105
1106    #[test]
1107    fn right_pane_shows_partial_footnote() {
1108        let backend = TestBackend::new(120, 30);
1109        let mut term = Terminal::new(backend).unwrap();
1110        let mut state = fixture();
1111        state.all[0].unreadable = 7;
1112        term.draw(|f| render(&mut state, f.area(), f.buffer_mut()))
1113            .unwrap();
1114        let dump = buffer_to_string(term.backend().buffer());
1115        assert!(
1116            dump.contains("partial: 7 entries unreadable"),
1117            "partial counter missing:\n{}",
1118            dump
1119        );
1120    }
1121
1122    #[test]
1123    fn draw_result_shows_freed_summary() {
1124        let backend = TestBackend::new(80, 20);
1125        let mut term = Terminal::new(backend).unwrap();
1126        let outcome = RunOutcome {
1127            freed: 1_500_000_000,
1128            deleted: 3,
1129            failed: 0,
1130            dry_run: false,
1131        };
1132        term.draw(|f| draw_result(&outcome, f.area(), f.buffer_mut()))
1133            .unwrap();
1134        let dump = buffer_to_string(term.backend().buffer());
1135        assert!(dump.contains("Freed"), "result summary missing:\n{}", dump);
1136        assert!(
1137            dump.contains("3 folders"),
1138            "deleted count missing:\n{}",
1139            dump
1140        );
1141    }
1142
1143    #[test]
1144    fn draw_result_shows_failed_suffix_when_failures() {
1145        let backend = TestBackend::new(80, 20);
1146        let mut term = Terminal::new(backend).unwrap();
1147        let outcome = RunOutcome {
1148            freed: 1_000,
1149            deleted: 2,
1150            failed: 1,
1151            dry_run: false,
1152        };
1153        term.draw(|f| draw_result(&outcome, f.area(), f.buffer_mut()))
1154            .unwrap();
1155        let dump = buffer_to_string(term.backend().buffer());
1156        assert!(
1157            dump.contains("1 failed"),
1158            "failed suffix missing:\n{}",
1159            dump
1160        );
1161    }
1162
1163    #[test]
1164    fn draw_result_dry_run_shows_would_free() {
1165        let backend = TestBackend::new(80, 20);
1166        let mut term = Terminal::new(backend).unwrap();
1167        let outcome = RunOutcome {
1168            freed: 1_000,
1169            deleted: 1,
1170            failed: 0,
1171            dry_run: true,
1172        };
1173        term.draw(|f| draw_result(&outcome, f.area(), f.buffer_mut()))
1174            .unwrap();
1175        let dump = buffer_to_string(term.backend().buffer());
1176        assert!(
1177            dump.contains("Would free"),
1178            "dry-run wording missing:\n{}",
1179            dump
1180        );
1181    }
1182
1183    #[test]
1184    fn footer_status_shows_total_count_when_no_filter() {
1185        let backend = TestBackend::new(120, 20);
1186        let mut term = Terminal::new(backend).unwrap();
1187        let mut state = fixture();
1188        term.draw(|f| render(&mut state, f.area(), f.buffer_mut()))
1189            .unwrap();
1190        let dump = buffer_to_string(term.backend().buffer());
1191        assert!(
1192            dump.contains("2 folders"),
1193            "total folder count missing:\n{}",
1194            dump
1195        );
1196        assert!(
1197            !dump.contains("filter:"),
1198            "filter label leaks when no filter is set:\n{}",
1199            dump
1200        );
1201    }
1202
1203    #[test]
1204    fn footer_status_shows_filter_substring_and_visible_count() {
1205        use crate::caches::tui::Filter;
1206        let backend = TestBackend::new(120, 20);
1207        let mut term = Terminal::new(backend).unwrap();
1208        let mut state = fixture();
1209        state.filter = Some(Filter {
1210            input: "alp".into(),
1211        });
1212        term.draw(|f| render(&mut state, f.area(), f.buffer_mut()))
1213            .unwrap();
1214        let dump = buffer_to_string(term.backend().buffer());
1215        assert!(
1216            dump.contains("1/2 folders"),
1217            "visible/total missing:\n{}",
1218            dump
1219        );
1220        assert!(
1221            dump.contains("filter: alp"),
1222            "filter substring missing:\n{}",
1223            dump
1224        );
1225    }
1226
1227    #[test]
1228    fn footer_status_hides_left_half_when_no_marks() {
1229        let backend = TestBackend::new(120, 20);
1230        let mut term = Terminal::new(backend).unwrap();
1231        let mut state = fixture();
1232        term.draw(|f| render(&mut state, f.area(), f.buffer_mut()))
1233            .unwrap();
1234        let dump = buffer_to_string(term.backend().buffer());
1235        assert!(
1236            !dump.contains("marked"),
1237            "marks label should be absent when count is zero:\n{}",
1238            dump
1239        );
1240    }
1241
1242    #[test]
1243    fn breadcrumb_reflects_drill_stack() {
1244        let backend = TestBackend::new(120, 20);
1245        let mut term = Terminal::new(backend).unwrap();
1246        let mut state = fixture();
1247        state.stack_labels.push("Library/Caches".into());
1248        state.stack_labels.push("Homebrew".into());
1249        term.draw(|f| render(&mut state, f.area(), f.buffer_mut()))
1250            .unwrap();
1251        let dump = buffer_to_string(term.backend().buffer());
1252        assert!(
1253            dump.contains("Library/Caches > Homebrew"),
1254            "breadcrumb missing:\n{}",
1255            dump
1256        );
1257    }
1258
1259    #[test]
1260    fn right_pane_shows_activity_sparkline() {
1261        let backend = TestBackend::new(120, 30);
1262        let mut term = Terminal::new(backend).unwrap();
1263        let mut state = fixture();
1264        state.all[0].top_files = vec![crate::caches::model::TopFile {
1265            name: "a".into(),
1266            size_bytes: 1,
1267            mtime: Some(SystemTime::UNIX_EPOCH + Duration::from_secs(90 * 86_400)),
1268        }];
1269        term.draw(|f| render(&mut state, f.area(), f.buffer_mut()))
1270            .unwrap();
1271        let dump = buffer_to_string(term.backend().buffer());
1272        assert!(
1273            dump.contains("Activity"),
1274            "activity header missing:\n{}",
1275            dump
1276        );
1277        assert!(
1278            SPARKS.iter().any(|&s| dump.contains(s)),
1279            "no spark char visible:\n{}",
1280            dump
1281        );
1282    }
1283
1284    #[test]
1285    fn render_active_mark_modal_shows_floor_days() {
1286        let backend = TestBackend::new(120, 30);
1287        let mut term = Terminal::new(backend).unwrap();
1288        let mut state = fixture();
1289        state.modal = crate::caches::tui::Modal::ActiveMark(vec![0]);
1290        term.draw(|f| render(&mut state, f.area(), f.buffer_mut()))
1291            .unwrap();
1292        let dump = buffer_to_string(term.backend().buffer());
1293        assert!(
1294            dump.contains("seems to be active"),
1295            "active modal text missing:\n{}",
1296            dump
1297        );
1298        assert!(
1299            dump.contains("< 7 days"),
1300            "floor wording missing:\n{}",
1301            dump
1302        );
1303    }
1304
1305    #[test]
1306    fn modal_renders_when_delete_requested() {
1307        let backend = TestBackend::new(80, 20);
1308        let mut term = Terminal::new(backend).unwrap();
1309        let mut state = fixture();
1310        state.marks.toggle(0);
1311        state.modal = crate::caches::tui::Modal::DeleteConfirm;
1312        term.draw(|f| render(&mut state, f.area(), f.buffer_mut()))
1313            .unwrap();
1314        let dump = buffer_to_string(term.backend().buffer());
1315        assert!(
1316            dump.contains("Delete 1 folder?"),
1317            "modal title missing:\n{}",
1318            dump
1319        );
1320        assert!(dump.contains("[Y] yes"), "modal Y default prompt missing");
1321        assert!(dump.contains("[n] cancel"), "modal n prompt missing");
1322    }
1323
1324    #[test]
1325    fn render_loading_modal_shows_spinner() {
1326        let backend = TestBackend::new(80, 20);
1327        let mut term = Terminal::new(backend).unwrap();
1328        let mut state = fixture();
1329        state.loading = Some(crate::caches::tui::Loading {
1330            label: "huggingface".into(),
1331            frame: 0,
1332            started: std::time::Instant::now(),
1333            folders: None,
1334        });
1335        term.draw(|f| render(&mut state, f.area(), f.buffer_mut()))
1336            .unwrap();
1337        let dump = buffer_to_string(term.backend().buffer());
1338        assert!(dump.contains("Loading"), "loading title missing:\n{}", dump);
1339        assert!(
1340            dump.contains("huggingface"),
1341            "loading label missing:\n{}",
1342            dump
1343        );
1344    }
1345
1346    #[test]
1347    fn render_loading_modal_shows_folder_count_when_set() {
1348        let backend = TestBackend::new(80, 20);
1349        let mut term = Terminal::new(backend).unwrap();
1350        let mut state = fixture();
1351        state.loading = Some(crate::caches::tui::Loading {
1352            label: "scanning caches".into(),
1353            frame: 0,
1354            started: std::time::Instant::now(),
1355            folders: Some(12_345),
1356        });
1357        term.draw(|f| render(&mut state, f.area(), f.buffer_mut()))
1358            .unwrap();
1359        let dump = buffer_to_string(term.backend().buffer());
1360        assert!(
1361            dump.contains("scanned 12.345 folders"),
1362            "expected folder-count line; got:\n{}",
1363            dump
1364        );
1365        assert!(
1366            !dump.contains("elapsed"),
1367            "elapsed should not appear when folder count is set:\n{}",
1368            dump
1369        );
1370    }
1371
1372    #[test]
1373    fn many_rows_renders_without_panic_at_cursor_50() {
1374        use crate::caches::tui::Msg;
1375        let mut state = fixture();
1376        // Replace fixture's two rows with 100 caches.
1377        state.all = (0..100u64)
1378            .map(|i| Cache {
1379                label: format!("c{i:03}"),
1380                path: PathBuf::from(format!("/x/c{i:03}")),
1381                size_bytes: 1024,
1382                newest_mtime: Some(SystemTime::UNIX_EPOCH + Duration::from_secs(i * 100)),
1383                file_count: 1,
1384                dir_count: 0,
1385                top_files: Vec::new(),
1386                unreadable: 0,
1387            })
1388            .collect();
1389
1390        for _ in 0..50 {
1391            state = crate::caches::tui::update(state, Msg::MoveDown).0;
1392        }
1393        assert_eq!(state.cursor, 50);
1394
1395        // Render at a small height; row c050 must scroll into view.
1396        let backend = TestBackend::new(80, 10);
1397        let mut term = Terminal::new(backend).unwrap();
1398        term.draw(|f| render(&mut state, f.area(), f.buffer_mut()))
1399            .unwrap();
1400        let dump = buffer_to_string(term.backend().buffer());
1401        assert!(dump.contains("c050"), "row c050 not rendered:\n{dump}");
1402    }
1403}