Skip to main content

null_e/tui/
mod.rs

1//! Terminal User Interface
2//!
3//! Interactive TUI for browsing and cleaning artifacts.
4//!
5//! This module provides a full-screen terminal interface using Ratatui.
6
7pub mod app;
8pub mod event;
9pub mod ui;
10
11pub use app::{App, AppState, ProjectEntry};
12pub use event::{Action, Event, EventHandler};
13
14use crate::error::Result;
15use crate::trash::{delete_path, DeleteMethod};
16use crossterm::{
17    event::{DisableMouseCapture, EnableMouseCapture, KeyCode},
18    execute,
19    terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
20};
21use ratatui::{backend::CrosstermBackend, Terminal};
22use std::io;
23use std::path::PathBuf;
24use std::time::Duration;
25
26/// Run the TUI application
27pub fn run(paths: Vec<PathBuf>) -> Result<()> {
28    // Setup terminal
29    enable_raw_mode()?;
30    let mut stdout = io::stdout();
31    execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
32    let backend = CrosstermBackend::new(stdout);
33    let mut terminal = Terminal::new(backend)?;
34
35    // Create app
36    let mut app = App::new(paths);
37
38    // Create event handler with faster tick rate for smooth animations
39    let events = EventHandler::new(Duration::from_millis(50));
40
41    // Main loop
42    let result = run_app(&mut terminal, &mut app, &events);
43
44    // Restore terminal
45    disable_raw_mode()?;
46    execute!(
47        terminal.backend_mut(),
48        LeaveAlternateScreen,
49        DisableMouseCapture
50    )?;
51    terminal.show_cursor()?;
52
53    result
54}
55
56/// Main application loop
57fn run_app(
58    terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
59    app: &mut App,
60    events: &EventHandler,
61) -> Result<()> {
62    loop {
63        // Draw UI
64        terminal.draw(|frame| ui::render(app, frame))?;
65
66        // Handle events
67        let event = events.next().map_err(|e| crate::error::DevSweepError::Other(e.to_string()))?;
68        match event {
69            Event::Key(key) => {
70                // Handle search mode separately
71                if app.is_searching {
72                    match key.code {
73                        KeyCode::Esc => app.end_search(),
74                        KeyCode::Enter => app.end_search(),
75                        KeyCode::Backspace => app.search_pop(),
76                        KeyCode::Char(c) => app.search_push(c),
77                        _ => {}
78                    }
79                    continue;
80                }
81
82                // Handle help popup
83                if app.show_help {
84                    app.show_help = false;
85                    continue;
86                }
87
88                // Convert key to action
89                let action = Action::from_key(key);
90
91                // Handle state-specific actions
92                match app.state {
93                    AppState::Scanning => {
94                        // Only allow quit during scanning
95                        if matches!(action, Action::Quit) {
96                            app.should_quit = true;
97                        }
98                    }
99                    AppState::Confirming => match action {
100                        Action::Confirm => {
101                            // Start deletion - actual delete happens on next tick
102                            app.start_delete();
103                        }
104                        Action::TogglePermanent => {
105                            app.toggle_permanent_delete();
106                        }
107                        Action::Cancel | Action::Quit => {
108                            app.cancel_delete();
109                        }
110                        _ => {}
111                    },
112                    AppState::Ready => match action {
113                        // Menu navigation
114                        Action::Quit => {
115                            app.should_quit = true;
116                        }
117                        Action::Up | Action::ScrollUp => app.menu_up(),
118                        Action::Down | Action::ScrollDown => app.menu_down(),
119                        Action::ToggleSelect | Action::Scan | Action::Confirm => {
120                            // Enter key or 's' key starts scan
121                            app.start_scan();
122                        }
123                        Action::Help => app.toggle_help(),
124                        _ => {}
125                    },
126                    AppState::Results | AppState::CacheResults | AppState::CleanerResults | AppState::Error(_) => match action {
127                        Action::Quit => {
128                            app.should_quit = true;
129                        }
130                        Action::Up => app.select_up(),
131                        Action::Down => app.select_down(),
132                        Action::PageUp => app.page_up(10),
133                        Action::PageDown => app.page_down(10),
134                        Action::Top => app.go_top(),
135                        Action::Bottom => app.go_bottom(),
136                        Action::ToggleSelect => app.toggle_select(),
137                        Action::ToggleExpand => app.toggle_expand(),
138                        Action::Expand => app.expand(),
139                        Action::Collapse => app.collapse(),
140                        Action::SelectAll => app.select_all(),
141                        Action::DeselectAll => app.deselect_all(),
142                        Action::Delete => app.request_delete(),
143                        Action::Help => app.toggle_help(),
144                        Action::Scan | Action::Refresh => {
145                            app.start_scan();
146                        }
147                        Action::Search => app.start_search(),
148                        Action::NextTab => app.next_tab(),
149                        Action::PrevTab => app.prev_tab(),
150                        Action::ScrollUp => app.scroll_up(),
151                        Action::ScrollDown => app.scroll_down(),
152                        Action::Back => {
153                            app.go_back();
154                        }
155                        Action::Cancel => {
156                            // Esc - go back to menu (or clear search if active)
157                            if !app.search_query.is_empty() {
158                                app.search_query.clear();
159                                app.filter_by_tab();
160                            } else {
161                                app.go_back();
162                            }
163                        }
164                        _ => {}
165                    },
166                    AppState::Cleaning => {
167                        // Allow quit during cleaning
168                        if matches!(action, Action::Quit | Action::Cancel) {
169                            app.pending_delete_items.clear();
170                            app.state = AppState::Results;
171                            app.status_message = Some("Cleaning cancelled".to_string());
172                        }
173                    }
174                }
175            }
176            Event::Tick => {
177                // Always tick animation for smooth UI
178                app.tick_animation();
179
180                // Check for scan updates on every tick
181                if app.state == AppState::Scanning {
182                    app.check_scan_progress();
183                }
184
185                // Process pending deletions (runs after UI has rendered Cleaning state)
186                if app.has_pending_delete() {
187                    let items = app.take_pending_delete_items();
188                    let permanent = app.permanent_delete;
189
190                    // Wrap in catch_unwind to handle panics gracefully
191                    let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
192                        delete_items(&items, permanent)
193                    }));
194
195                    match result {
196                        Ok((success, failed, freed)) => {
197                            app.deletion_complete(success, failed, freed);
198                        }
199                        Err(_) => {
200                            // Panic during deletion - recover gracefully
201                            app.deletion_complete(0, items.len(), 0);
202                            app.status_message = Some("Error during deletion!".to_string());
203                        }
204                    }
205                }
206            }
207            Event::Resize(_, _) => {
208                // Terminal will redraw on next iteration
209            }
210            Event::Mouse(mouse) => {
211                // Handle mouse events (scroll wheel)
212                let action = Action::from_mouse(&mouse);
213                match action {
214                    Action::ScrollUp => {
215                        app.select_up();
216                        app.select_up();
217                        app.select_up();
218                    }
219                    Action::ScrollDown => {
220                        app.select_down();
221                        app.select_down();
222                        app.select_down();
223                    }
224                    _ => {}
225                }
226            }
227        }
228
229        // Check if we should quit
230        if app.should_quit {
231            break;
232        }
233    }
234
235    Ok(())
236}
237
238/// Delete items and return (success_count, fail_count, bytes_freed)
239/// Items are tuples of (path, optional clean_command)
240/// If clean_command is Some, run that command instead of deleting the path
241fn delete_items(items: &[(PathBuf, Option<String>)], permanent: bool) -> (usize, usize, u64) {
242    let mut success = 0;
243    let mut failed = 0;
244    let mut freed = 0u64;
245
246    let method = if permanent {
247        DeleteMethod::Permanent
248    } else {
249        DeleteMethod::Trash
250    };
251
252    for (path, clean_command) in items {
253        // Get size before deletion
254        let size = if path.is_dir() {
255            dir_size(path)
256        } else {
257            std::fs::metadata(path).map(|m| m.len()).unwrap_or(0)
258        };
259
260        // If there's a clean_command, run it instead of deleting the path
261        let result = if let Some(cmd) = clean_command {
262            // Run the clean command (e.g., "docker rmi abc123")
263            run_clean_command(cmd)
264        } else {
265            // Delete using selected method
266            delete_path(path, method).map(|_| ())
267        };
268
269        match result {
270            Ok(_) => {
271                success += 1;
272                freed += size;
273            }
274            Err(_) => {
275                failed += 1;
276            }
277        }
278    }
279
280    (success, failed, freed)
281}
282
283/// Run a shell command for cleaning (Docker, etc.)
284fn run_clean_command(cmd: &str) -> crate::error::Result<()> {
285    use std::process::Command;
286
287    let output = if cfg!(target_os = "windows") {
288        Command::new("cmd").args(["/C", cmd]).output()
289    } else {
290        Command::new("sh").args(["-c", cmd]).output()
291    };
292
293    match output {
294        Ok(out) if out.status.success() => Ok(()),
295        Ok(out) => Err(crate::error::DevSweepError::Other(
296            String::from_utf8_lossy(&out.stderr).to_string(),
297        )),
298        Err(e) => Err(crate::error::DevSweepError::Other(e.to_string())),
299    }
300}
301
302/// Calculate directory size
303fn dir_size(path: &PathBuf) -> u64 {
304    walkdir::WalkDir::new(path)
305        .into_iter()
306        .filter_map(|e| e.ok())
307        .filter(|e| e.file_type().is_file())
308        .filter_map(|e| e.metadata().ok())
309        .map(|m| m.len())
310        .sum()
311}