acme_disk_use/
tui.rs

1//! TUI module for displaying cached disk usage statistics
2//!
3//! Provides an ncdu-like interface for navigating and viewing directory sizes
4
5use std::io::{self, stdout};
6use std::path::PathBuf;
7
8use crossterm::{
9    event::{self, Event, KeyCode, KeyEventKind},
10    terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
11    ExecutableCommand,
12};
13use ratatui::{
14    prelude::*,
15    widgets::{Block, Borders, List, ListItem, ListState, Paragraph},
16};
17
18use crate::format_size;
19use crate::scanner::DirStat;
20
21/// Entry in the TUI directory list
22struct DirEntry {
23    path: PathBuf,
24    name: String,
25    size: u64,
26    file_count: u64,
27    has_children: bool,
28}
29
30/// State for the TUI application
31struct App<'a> {
32    /// Stored roots for lookup when at root level
33    roots: Vec<&'a DirStat>,
34    /// Stack of directory stats for navigation (parent directories)
35    path_stack: Vec<&'a DirStat>,
36    /// Current directory being viewed (None means we're at the roots list)
37    current: Option<&'a DirStat>,
38    /// List of entries in the current directory
39    entries: Vec<DirEntry>,
40    /// Currently selected index
41    list_state: ListState,
42    /// Should quit
43    should_quit: bool,
44}
45
46impl<'a> App<'a> {
47    /// Convert a DirStat to a DirEntry
48    fn make_entry(stat: &DirStat) -> DirEntry {
49        DirEntry {
50            path: stat.path().to_path_buf(),
51            name: stat
52                .path()
53                .file_name()
54                .map(|n| n.to_string_lossy().to_string())
55                .unwrap_or_else(|| stat.path().display().to_string()),
56            size: stat.total_size(),
57            file_count: stat.file_count(),
58            has_children: !stat.children().is_empty(),
59        }
60    }
61
62    /// Sort entries by size (descending) and select first item
63    fn finalize_entries(&mut self) {
64        self.entries.sort_by(|a, b| b.size.cmp(&a.size));
65        if !self.entries.is_empty() {
66            self.list_state.select(Some(0));
67        } else {
68            self.list_state.select(None);
69        }
70    }
71
72    fn new(roots: Vec<&'a DirStat>) -> Self {
73        let entries: Vec<DirEntry> = roots.iter().map(|stat| Self::make_entry(stat)).collect();
74
75        let mut app = Self {
76            roots,
77            path_stack: Vec::new(),
78            current: None,
79            entries,
80            list_state: ListState::default(),
81            should_quit: false,
82        };
83
84        app.finalize_entries();
85        app
86    }
87
88    fn from_stat(stat: &'a DirStat) -> Self {
89        let mut app = Self {
90            roots: vec![stat],
91            path_stack: Vec::new(),
92            current: Some(stat),
93            entries: Vec::new(),
94            list_state: ListState::default(),
95            should_quit: false,
96        };
97
98        app.populate_entries_from_current();
99        app
100    }
101
102    fn populate_entries_from_current(&mut self) {
103        if let Some(stat) = self.current {
104            self.entries = stat.children().values().map(Self::make_entry).collect();
105        }
106        self.finalize_entries();
107    }
108
109    fn populate_entries_from_roots(&mut self) {
110        self.entries = self
111            .roots
112            .iter()
113            .map(|stat| Self::make_entry(stat))
114            .collect();
115        self.finalize_entries();
116    }
117
118    fn get_current_path(&self) -> String {
119        if let Some(stat) = self.current {
120            stat.path().display().to_string()
121        } else {
122            "Cached Roots".to_string()
123        }
124    }
125
126    fn get_current_total_size(&self) -> u64 {
127        if let Some(stat) = self.current {
128            stat.total_size()
129        } else {
130            self.entries.iter().map(|e| e.size).sum()
131        }
132    }
133
134    fn move_up(&mut self) {
135        if let Some(selected) = self.list_state.selected() {
136            if selected > 0 {
137                self.list_state.select(Some(selected - 1));
138            }
139        }
140    }
141
142    fn move_down(&mut self) {
143        if let Some(selected) = self.list_state.selected() {
144            if selected < self.entries.len().saturating_sub(1) {
145                self.list_state.select(Some(selected + 1));
146            }
147        }
148    }
149
150    fn enter_selected(&mut self) {
151        if let Some(selected) = self.list_state.selected() {
152            if selected < self.entries.len() && self.entries[selected].has_children {
153                let selected_path = &self.entries[selected].path;
154
155                // Find the DirStat for the selected entry
156                let child_stat = if let Some(current) = self.current {
157                    // We're inside a directory, look in its children
158                    current.children().get(selected_path)
159                } else {
160                    // We're at root level, look in the roots
161                    self.roots
162                        .iter()
163                        .find(|r| r.path() == selected_path)
164                        .copied()
165                };
166
167                if let Some(stat) = child_stat {
168                    // Push current to stack and navigate to child
169                    if let Some(current) = self.current {
170                        self.path_stack.push(current);
171                    }
172                    self.current = Some(stat);
173                    self.populate_entries_from_current();
174                }
175            }
176        }
177    }
178
179    fn go_back(&mut self) {
180        if let Some(parent) = self.path_stack.pop() {
181            self.current = Some(parent);
182            self.populate_entries_from_current();
183        } else if self.current.is_some() {
184            // We're at a root, go back to roots list
185            self.current = None;
186            self.populate_entries_from_roots();
187        }
188    }
189
190    fn handle_key(&mut self, code: KeyCode) {
191        match code {
192            KeyCode::Char('q') | KeyCode::Esc => self.should_quit = true,
193            KeyCode::Up | KeyCode::Char('k') => self.move_up(),
194            KeyCode::Down | KeyCode::Char('j') => self.move_down(),
195            KeyCode::Enter | KeyCode::Right | KeyCode::Char('l') => self.enter_selected(),
196            KeyCode::Backspace | KeyCode::Left | KeyCode::Char('h') => self.go_back(),
197            _ => {}
198        }
199    }
200}
201
202/// Run the TUI with a single DirStat (the root of a scanned directory)
203pub fn run_tui(stat: &DirStat) -> io::Result<()> {
204    // Setup terminal
205    enable_raw_mode()?;
206    stdout().execute(EnterAlternateScreen)?;
207
208    let mut terminal = Terminal::new(CrosstermBackend::new(stdout()))?;
209    let mut app = App::from_stat(stat);
210
211    // Main loop
212    loop {
213        terminal.draw(|frame| render(&mut app, frame))?;
214
215        if let Event::Key(key) = event::read()? {
216            if key.kind == KeyEventKind::Press {
217                app.handle_key(key.code);
218            }
219        }
220
221        if app.should_quit {
222            break;
223        }
224    }
225
226    // Cleanup
227    disable_raw_mode()?;
228    stdout().execute(LeaveAlternateScreen)?;
229
230    Ok(())
231}
232
233/// Run the TUI with multiple cached roots
234pub fn run_tui_with_roots(roots: Vec<&DirStat>) -> io::Result<()> {
235    if roots.is_empty() {
236        return Err(io::Error::new(
237            io::ErrorKind::NotFound,
238            "No cached directories found",
239        ));
240    }
241
242    // Setup terminal
243    enable_raw_mode()?;
244    stdout().execute(EnterAlternateScreen)?;
245
246    let mut terminal = Terminal::new(CrosstermBackend::new(stdout()))?;
247    let mut app = App::new(roots);
248
249    // Main loop
250    loop {
251        terminal.draw(|frame| render(&mut app, frame))?;
252
253        if let Event::Key(key) = event::read()? {
254            if key.kind == KeyEventKind::Press {
255                app.handle_key(key.code);
256            }
257        }
258
259        if app.should_quit {
260            break;
261        }
262    }
263
264    // Cleanup
265    disable_raw_mode()?;
266    stdout().execute(LeaveAlternateScreen)?;
267
268    Ok(())
269}
270
271fn render(app: &mut App, frame: &mut Frame) {
272    let area = frame.area();
273
274    // Create layout with header, main content, and footer
275    let chunks = Layout::default()
276        .direction(Direction::Vertical)
277        .constraints([
278            Constraint::Length(3), // Header
279            Constraint::Min(0),    // Content
280            Constraint::Length(3), // Footer
281        ])
282        .split(area);
283
284    // Header with current path and total size
285    let total_size = format_size(app.get_current_total_size(), true);
286    let header_text = format!("{} (Total: {})", app.get_current_path(), total_size);
287    let header = Paragraph::new(header_text)
288        .style(
289            Style::default()
290                .fg(Color::Cyan)
291                .add_modifier(Modifier::BOLD),
292        )
293        .block(
294            Block::default()
295                .borders(Borders::ALL)
296                .title("acme-disk-use"),
297        );
298    frame.render_widget(header, chunks[0]);
299
300    // Content - directory listing
301    let items: Vec<ListItem> = app
302        .entries
303        .iter()
304        .map(|entry| {
305            let size_str = format_size(entry.size, true);
306            let indicator = if entry.has_children { "/" } else { "" };
307            let line = format!(
308                "{:>12}  {:>6} files  {}{}",
309                size_str, entry.file_count, entry.name, indicator
310            );
311            ListItem::new(line)
312        })
313        .collect();
314
315    let list = List::new(items)
316        .block(Block::default().borders(Borders::ALL).title("Directories"))
317        .highlight_style(
318            Style::default()
319                .bg(Color::DarkGray)
320                .add_modifier(Modifier::BOLD),
321        )
322        .highlight_symbol("> ");
323
324    frame.render_stateful_widget(list, chunks[1], &mut app.list_state);
325
326    // Footer with help text
327    let help_text = "↑/k: Up | ↓/j: Down | Enter/→/l: Open | Backspace/←/h: Back | q/Esc: Quit";
328    let footer = Paragraph::new(help_text)
329        .style(Style::default().fg(Color::DarkGray))
330        .block(Block::default().borders(Borders::ALL).title("Help"));
331    frame.render_widget(footer, chunks[2]);
332}
333
334#[cfg(test)]
335mod tests {
336    use super::*;
337    use std::collections::HashMap;
338    use std::time::SystemTime;
339
340    fn create_test_stat() -> DirStat {
341        DirStat {
342            path: PathBuf::from("/test"),
343            total_size: 1000,
344            file_count: 10,
345            last_scan: SystemTime::now(),
346            children: HashMap::new(),
347        }
348    }
349
350    #[test]
351    fn test_dir_entry_sorting() {
352        // Create test stats with different sizes
353        let stat1 = DirStat {
354            path: PathBuf::from("/test/small"),
355            total_size: 100,
356            file_count: 1,
357            last_scan: SystemTime::now(),
358            children: HashMap::new(),
359        };
360
361        let stat2 = DirStat {
362            path: PathBuf::from("/test/large"),
363            total_size: 1000,
364            file_count: 10,
365            last_scan: SystemTime::now(),
366            children: HashMap::new(),
367        };
368
369        let roots: Vec<&DirStat> = vec![&stat1, &stat2];
370        let app = App::new(roots);
371
372        // Verify entries are sorted by size descending
373        assert_eq!(app.entries.len(), 2);
374        assert_eq!(app.entries[0].size, 1000); // Large first
375        assert_eq!(app.entries[1].size, 100); // Small second
376    }
377
378    #[test]
379    fn test_app_navigation() {
380        let stat = create_test_stat();
381        let mut app = App::from_stat(&stat);
382
383        // Test that navigation doesn't crash with empty entries
384        app.move_up();
385        app.move_down();
386        app.enter_selected();
387        app.go_back();
388    }
389
390    #[test]
391    fn test_go_back_to_roots() {
392        // Create a root stat with children
393        let child = DirStat {
394            path: PathBuf::from("/test/child"),
395            total_size: 500,
396            file_count: 5,
397            last_scan: SystemTime::now(),
398            children: HashMap::new(),
399        };
400
401        let mut children = HashMap::new();
402        children.insert(PathBuf::from("/test/child"), child);
403
404        let root = DirStat {
405            path: PathBuf::from("/test"),
406            total_size: 1000,
407            file_count: 10,
408            last_scan: SystemTime::now(),
409            children,
410        };
411
412        let roots: Vec<&DirStat> = vec![&root];
413        let mut app = App::new(roots);
414
415        // Start at roots list
416        assert!(app.current.is_none());
417        assert_eq!(app.get_current_path(), "Cached Roots");
418
419        // Navigate into root
420        app.enter_selected();
421        assert!(app.current.is_some());
422        assert_eq!(app.get_current_path(), "/test");
423
424        // Go back to roots
425        app.go_back();
426        assert!(app.current.is_none());
427        assert_eq!(app.get_current_path(), "Cached Roots");
428    }
429}