Skip to main content

disk_cleaner/tui/
mod.rs

1mod app;
2mod event;
3mod ui;
4
5use std::error::Error;
6use std::io;
7use std::path::Path;
8use std::sync::mpsc;
9
10use crossterm::{
11    event as crossterm_event,
12    terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
13};
14use ratatui::backend::CrosstermBackend;
15use ratatui::Terminal;
16
17use crate::{analyze, DiskItem, FileInfo, ScanOptions};
18
19type Tui = Terminal<CrosstermBackend<io::Stdout>>;
20
21/// Message sent from scan thread to main thread.
22struct ScanResult {
23    /// Pre-flattened items (already computed in background).
24    items: Option<Vec<app::FlatItem>>,
25    path: String,
26    total_size: u64,
27    // Populated on scan failure but not currently surfaced (the user stays on
28    // the last view); retained for future error reporting.
29    #[allow(dead_code)]
30    error: Option<String>,
31}
32
33/// Message sent from the projects-scan thread to the main thread.
34struct ProjectScanResult {
35    entries: Vec<app::ProjectEntry>,
36    /// Non-empty after a clean-all: per-project failures to surface to the user.
37    errors: Vec<String>,
38}
39
40/// Run the interactive TUI with an explicit apparent flag.
41#[allow(dead_code)]
42pub fn run_with_apparent(
43    root: DiskItem,
44    root_path: String,
45    total_size: u64,
46    apparent: bool,
47) -> Result<(), Box<dyn Error>> {
48    enable_raw_mode()?;
49    let mut stdout = io::stdout();
50    crossterm::execute!(stdout, EnterAlternateScreen)?;
51    let backend = CrosstermBackend::new(stdout);
52    let mut terminal = Terminal::new(backend)?;
53
54    let mut state =
55        app::AppState::from_disk_item_with_apparent(root, root_path, total_size, apparent);
56
57    let res = run_loop(&mut terminal, &mut state);
58
59    disable_raw_mode()?;
60    crossterm::execute!(io::stdout(), LeaveAlternateScreen)?;
61    terminal.show_cursor()?;
62    res
63}
64
65/// Run the interactive TUI starting with a path (shows loading splash while scanning).
66pub fn run_from_path(path: String, apparent: bool) -> Result<(), Box<dyn Error>> {
67    enable_raw_mode()?;
68    let mut stdout = io::stdout();
69    crossterm::execute!(stdout, EnterAlternateScreen)?;
70    let backend = CrosstermBackend::new(stdout);
71    let mut terminal = Terminal::new(backend)?;
72
73    let mut state = app::AppState::new_empty(path.clone(), apparent);
74
75    let res = run_loop(&mut terminal, &mut state);
76
77    disable_raw_mode()?;
78    crossterm::execute!(io::stdout(), LeaveAlternateScreen)?;
79    terminal.show_cursor()?;
80    res
81}
82
83fn run_loop(terminal: &mut Tui, state: &mut app::AppState) -> Result<(), Box<dyn Error>> {
84    let (scan_tx, scan_rx) = mpsc::channel::<ScanResult>();
85    let (project_tx, project_rx) = mpsc::channel::<ProjectScanResult>();
86
87    // Kick off initial shallow scan
88    if state.loading {
89        start_shallow_scan(&scan_tx, state.root_path.clone(), state.apparent, state);
90    }
91
92    loop {
93        // Check if a disk scan completed
94        if state.loading {
95            if let Ok(result) = scan_rx.try_recv() {
96                state.loading = false;
97                if let Some(items) = result.items {
98                    state.root_path = result.path;
99                    state.total_size = result.total_size;
100                    state.rebuild_from_items(items);
101                }
102                // If error, loading is cancelled, user stays on current view
103            } else {
104                state.loading_frame = state.loading_frame.wrapping_add(1);
105            }
106        }
107
108        // Check if a projects scan completed
109        if state.projects_loading {
110            if let Ok(result) = project_rx.try_recv() {
111                state.projects_loading = false;
112                state.set_projects(result.entries);
113                if !result.errors.is_empty() {
114                    state.error_message = Some(format!(
115                        "{} project(s) failed to clean: {}",
116                        result.errors.len(),
117                        result.errors.join("; ")
118                    ));
119                }
120            } else {
121                state.loading_frame = state.loading_frame.wrapping_add(1);
122            }
123        }
124
125        let viewport_height = terminal.size()?.height as usize;
126        let mode_loading = match state.mode {
127            app::AppMode::Disk => state.loading,
128            app::AppMode::Projects => state.projects_loading,
129        };
130        if !mode_loading {
131            let list_height = viewport_height.saturating_sub(2);
132            match state.mode {
133                app::AppMode::Disk => state.adjust_scroll(list_height),
134                app::AppMode::Projects => state.projects_adjust_scroll(list_height),
135            }
136        }
137
138        let detail = state.detail_stats_cloned();
139        let project_detail = state.project_detail_cloned();
140        terminal.draw(|f| ui::render(f, state, &detail, &project_detail))?;
141
142        if crossterm_event::poll(std::time::Duration::from_millis(100))? {
143            match crossterm_event::read()? {
144                crossterm_event::Event::Key(key) => {
145                    if mode_loading {
146                        if let Some(action) = event::handle_key(key) {
147                            if let event::AppAction::Quit = action {
148                                break;
149                            }
150                        }
151                        continue;
152                    }
153
154                    if state.search_active {
155                        match event::handle_search_key(key) {
156                            event::SearchAction::Char(c) => {
157                                state.search_query.push(c);
158                                state.apply_search();
159                            }
160                            event::SearchAction::Backspace => {
161                                state.search_query.pop();
162                                state.apply_search();
163                            }
164                            event::SearchAction::Finish => {
165                                state.search_active = false;
166                                state.apply_search();
167                            }
168                            event::SearchAction::Ignore => {}
169                        }
170                        continue;
171                    }
172
173                    // Delete confirmation dialog mode (disk)
174                    if state.delete_target.is_some() {
175                        match event::handle_delete_confirm_key(key) {
176                            event::DeleteConfirmAction::Confirm => {
177                                if let Some((path, is_dir)) = state.delete_target_info() {
178                                    let result = if is_dir {
179                                        std::fs::remove_dir_all(&path)
180                                    } else {
181                                        std::fs::remove_file(&path)
182                                    };
183                                    match result {
184                                        Ok(()) => {
185                                            state.cancel_delete();
186                                            // Rescan to refresh the view
187                                            start_shallow_scan(
188                                                &scan_tx,
189                                                state.root_path.clone(),
190                                                state.apparent,
191                                                state,
192                                            );
193                                        }
194                                        Err(e) => {
195                                            state.error_message = Some(format!("Delete failed: {}", e));
196                                            state.cancel_delete();
197                                        }
198                                    }
199                                } else {
200                                    state.cancel_delete();
201                                }
202                            }
203                            event::DeleteConfirmAction::Cancel => {
204                                state.cancel_delete();
205                            }
206                            event::DeleteConfirmAction::Ignore => {}
207                        }
208                        continue;
209                    }
210
211                    // Clean confirmation dialog mode (projects)
212                    if state.clean_target.is_some() {
213                        match event::handle_delete_confirm_key(key) {
214                            event::DeleteConfirmAction::Confirm => {
215                                let path = state
216                                    .clean_target
217                                    .and_then(|i| state.projects.get(i).map(|e| e.path.clone()));
218                                match path {
219                                    Some(path) => {
220                                        match crate::clean(std::path::Path::new(&path)) {
221                                            Ok(()) => {
222                                                state.remove_cleaned_project();
223                                                // Files were deleted: the cached disk
224                                                // tree is now stale.
225                                                state.disk_stale = true;
226                                            }
227                                            Err(e) => {
228                                                state.error_message =
229                                                    Some(format!("Clean failed: {}", e));
230                                                state.cancel_clean();
231                                            }
232                                        }
233                                    }
234                                    None => state.cancel_clean(),
235                                }
236                            }
237                            event::DeleteConfirmAction::Cancel => state.cancel_clean(),
238                            event::DeleteConfirmAction::Ignore => {}
239                        }
240                        continue;
241                    }
242
243                    // Clean-ALL confirmation dialog mode (projects)
244                    if state.clean_all_pending {
245                        match event::handle_delete_confirm_key(key) {
246                            event::DeleteConfirmAction::Confirm => {
247                                start_clean_all(&project_tx, state);
248                            }
249                            event::DeleteConfirmAction::Cancel => state.cancel_clean_all(),
250                            event::DeleteConfirmAction::Ignore => {}
251                        }
252                        continue;
253                    }
254
255                    if let Some(action) = event::handle_key(key) {
256                        // Any normal action dismisses a prior error message.
257                        state.error_message = None;
258
259                        // Mode toggle works in both views.
260                        if let event::AppAction::ToggleMode = action {
261                            state.mode = match state.mode {
262                                app::AppMode::Disk => app::AppMode::Projects,
263                                app::AppMode::Projects => app::AppMode::Disk,
264                            };
265                            match state.mode {
266                                // Entering Projects mode: scan the directory
267                                // under the cursor (selected node), falling back
268                                // to the scan root when a file or nothing is
269                                // selected.
270                                app::AppMode::Projects if !state.projects_loading => {
271                                    let scan_target = state
272                                        .visible
273                                        .get(state.selected)
274                                        .filter(|&&idx| state.items[idx].has_children)
275                                        .map(|&idx| state.items[idx].full_path.clone())
276                                        .unwrap_or_else(|| state.root_path.clone());
277                                    state.projects_scan_root = scan_target.clone();
278                                    start_projects_scan(&project_tx, scan_target, state);
279                                }
280                                // Returning to Disk mode after a clean: the
281                                // cached tree is stale, so re-scan it.
282                                app::AppMode::Disk if state.disk_stale && !state.loading => {
283                                    state.disk_stale = false;
284                                    start_shallow_scan(
285                                        &scan_tx,
286                                        state.root_path.clone(),
287                                        state.apparent,
288                                        state,
289                                    );
290                                }
291                                _ => {}
292                            }
293                            continue;
294                        }
295
296                        match state.mode {
297                            app::AppMode::Disk => match action {
298                                event::AppAction::Quit => break,
299                                event::AppAction::Up => state.move_up(),
300                                event::AppAction::Down => state.move_down(),
301                                event::AppAction::Enter => state.enter(),
302                                event::AppAction::Back => state.back(),
303                                event::AppAction::Toggle => state.toggle_expand(),
304                                event::AppAction::JumpTop => state.jump_top(),
305                                event::AppAction::JumpBottom => state.jump_bottom(),
306                                event::AppAction::ToggleHidden => {
307                                    state.show_hidden = !state.show_hidden;
308                                    state.compute_visible();
309                                }
310                                event::AppAction::DeleteItem => {
311                                    state.request_delete();
312                                }
313                                event::AppAction::ToggleApparent => {
314                                    state.apparent = !state.apparent;
315                                    start_shallow_scan(
316                                        &scan_tx,
317                                        state.root_path.clone(),
318                                        state.apparent,
319                                        state,
320                                    );
321                                }
322                                event::AppAction::StartSearch => {
323                                    state.search_active = true;
324                                    state.search_query.clear();
325                                }
326                                event::AppAction::EnterDir => {
327                                    state.enter_dir();
328                                    if let Some(new_path) = state.rescan_path.take() {
329                                        start_shallow_scan(
330                                            &scan_tx,
331                                            new_path,
332                                            state.apparent,
333                                            state,
334                                        );
335                                    }
336                                }
337                                event::AppAction::ParentDir => {
338                                    state.parent_dir();
339                                    if let Some(new_path) = state.rescan_path.take() {
340                                        start_shallow_scan(
341                                            &scan_tx,
342                                            new_path,
343                                            state.apparent,
344                                            state,
345                                        );
346                                    }
347                                }
348                                event::AppAction::Refresh => {
349                                    start_shallow_scan(
350                                        &scan_tx,
351                                        state.root_path.clone(),
352                                        state.apparent,
353                                        state,
354                                    );
355                                }
356                                _ => {}
357                            },
358                            app::AppMode::Projects => match action {
359                                event::AppAction::Quit => break,
360                                event::AppAction::Up => state.projects_up(),
361                                event::AppAction::Down => state.projects_down(),
362                                event::AppAction::JumpTop => state.projects_jump_top(),
363                                event::AppAction::JumpBottom => state.projects_jump_bottom(),
364                                event::AppAction::Enter | event::AppAction::CleanProject => {
365                                    state.request_clean();
366                                }
367                                event::AppAction::CleanAllProjects => state.request_clean_all(),
368                                event::AppAction::Refresh => {
369                                    let target = if state.projects_scan_root.is_empty() {
370                                        state.root_path.clone()
371                                    } else {
372                                        state.projects_scan_root.clone()
373                                    };
374                                    start_projects_scan(&project_tx, target, state);
375                                }
376                                _ => {}
377                            },
378                        }
379                    }
380                }
381                _ => {}
382            }
383        }
384    }
385    Ok(())
386}
387
388/// Start a background project scan with [`analyze`], collecting every project
389/// (with reclaimable size + type) found beneath `path`.
390fn start_projects_scan(
391    tx: &mpsc::Sender<ProjectScanResult>,
392    path: String,
393    state: &mut app::AppState,
394) {
395    state.projects_loading = true;
396    state.loading_frame = 0;
397    state.loading_message = format!("projects in {}", path);
398
399    let tx = tx.clone();
400    let path_owned = path;
401    let apparent = state.apparent;
402    std::thread::spawn(move || {
403        let opts = ScanOptions {
404            follow_symlinks: false,
405            same_file_system: false,
406            apparent,
407        };
408        let entries: Vec<app::ProjectEntry> = analyze(&path_owned, &opts)
409            .map(|pa| app::ProjectEntry {
410                path: pa.project.path.to_string_lossy().into_owned(),
411                type_name: pa.project.type_name(),
412                reclaimable: pa.artifact_size,
413                last_modified: pa.last_modified,
414                artifact_dir_names: pa
415                    .project
416                    .artifact_dirs()
417                    .into_iter()
418                    .map(String::from)
419                    .collect(),
420            })
421            .collect();
422        let _ = tx.send(ProjectScanResult {
423            entries,
424            errors: Vec::new(),
425        });
426    });
427}
428
429/// Clean every project in the current list, then re-scan the same root so the
430/// list reflects reality (partially-cleaned or permission-denied projects stay
431/// listed). Runs in the background; failures are surfaced via `error_message`.
432fn start_clean_all(tx: &mpsc::Sender<ProjectScanResult>, state: &mut app::AppState) {
433    let paths: Vec<String> = state.projects.iter().map(|e| e.path.clone()).collect();
434    let root_path = if state.projects_scan_root.is_empty() {
435        state.root_path.clone()
436    } else {
437        state.projects_scan_root.clone()
438    };
439    let count = paths.len();
440    state.projects_loading = true;
441    state.loading_frame = 0;
442    state.loading_message = format!("cleaning {} projects", count);
443    state.clean_all_pending = false;
444    state.disk_stale = true;
445
446    let tx = tx.clone();
447    let apparent = state.apparent;
448    std::thread::spawn(move || {
449        let opts = ScanOptions {
450            follow_symlinks: false,
451            same_file_system: false,
452            apparent,
453        };
454        let mut errors: Vec<String> = Vec::new();
455        for path in &paths {
456            if let Err(e) = crate::clean(std::path::Path::new(path)) {
457                errors.push(format!("{} ({})", path, e));
458            }
459        }
460        // Re-analyze the same root: successfully cleaned projects now have zero
461        // artifacts and drop out; failed ones remain with their sizes updated.
462        let entries: Vec<app::ProjectEntry> = analyze(&root_path, &opts)
463            .map(|pa| app::ProjectEntry {
464                path: pa.project.path.to_string_lossy().into_owned(),
465                type_name: pa.project.type_name(),
466                reclaimable: pa.artifact_size,
467                last_modified: pa.last_modified,
468                artifact_dir_names: pa
469                    .project
470                    .artifact_dirs()
471                    .into_iter()
472                    .map(String::from)
473                    .collect(),
474            })
475            .collect();
476        let _ = tx.send(ProjectScanResult { entries, errors });
477    });
478}
479
480/// Start a background shallow scan (one level only).
481fn start_shallow_scan(
482    tx: &mpsc::Sender<ScanResult>,
483    path: String,
484    apparent: bool,
485    state: &mut app::AppState,
486) {
487    state.loading = true;
488    state.loading_frame = 0;
489    state.loading_message = format!("{}", path);
490
491    let tx = tx.clone();
492    let path_owned = path;
493    std::thread::spawn(move || {
494        let result = do_shallow_scan(&path_owned, apparent);
495        let msg = match result {
496            Ok(root) => {
497                let total_size = root.disk_size;
498                // Flatten in the background thread — no main thread freeze
499                let mut items = Vec::new();
500                app::flatten_disk_item(&root, path_owned.clone(), total_size, 0, None, &mut items);
501                ScanResult {
502                    items: Some(items),
503                    path: path_owned,
504                    total_size,
505                    error: None,
506                }
507            }
508            Err(e) => ScanResult {
509                items: None,
510                path: path_owned,
511                total_size: 0,
512                error: Some(format!("{}", e)),
513            },
514        };
515        let _ = tx.send(msg);
516    });
517}
518
519/// Perform a one-level shallow scan of a directory.
520fn do_shallow_scan(path: &str, apparent: bool) -> Result<DiskItem, Box<dyn Error>> {
521    let target = Path::new(path);
522    let file_info = FileInfo::from_path(target, apparent)?;
523    match file_info {
524        FileInfo::Directory { volume_id } => {
525            DiskItem::from_shallow_scan(target, apparent, volume_id)
526        }
527        _ => Err(format!("{} is not a directory!", path).into()),
528    }
529}