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    std::thread::spawn(move || {
402        let opts = ScanOptions {
403            follow_symlinks: false,
404            same_file_system: false,
405        };
406        let entries: Vec<app::ProjectEntry> = analyze(&path_owned, &opts)
407            .map(|pa| app::ProjectEntry {
408                path: pa.project.path.to_string_lossy().into_owned(),
409                type_name: pa.project.type_name(),
410                reclaimable: pa.artifact_size,
411                last_modified: pa.last_modified,
412                artifact_dir_names: pa
413                    .project
414                    .artifact_dirs()
415                    .into_iter()
416                    .map(String::from)
417                    .collect(),
418            })
419            .collect();
420        let _ = tx.send(ProjectScanResult {
421            entries,
422            errors: Vec::new(),
423        });
424    });
425}
426
427/// Clean every project in the current list, then re-scan the same root so the
428/// list reflects reality (partially-cleaned or permission-denied projects stay
429/// listed). Runs in the background; failures are surfaced via `error_message`.
430fn start_clean_all(tx: &mpsc::Sender<ProjectScanResult>, state: &mut app::AppState) {
431    let paths: Vec<String> = state.projects.iter().map(|e| e.path.clone()).collect();
432    let root_path = if state.projects_scan_root.is_empty() {
433        state.root_path.clone()
434    } else {
435        state.projects_scan_root.clone()
436    };
437    let count = paths.len();
438    state.projects_loading = true;
439    state.loading_frame = 0;
440    state.loading_message = format!("cleaning {} projects", count);
441    state.clean_all_pending = false;
442    state.disk_stale = true;
443
444    let tx = tx.clone();
445    std::thread::spawn(move || {
446        let opts = ScanOptions {
447            follow_symlinks: false,
448            same_file_system: false,
449        };
450        let mut errors: Vec<String> = Vec::new();
451        for path in &paths {
452            if let Err(e) = crate::clean(std::path::Path::new(path)) {
453                errors.push(format!("{} ({})", path, e));
454            }
455        }
456        // Re-analyze the same root: successfully cleaned projects now have zero
457        // artifacts and drop out; failed ones remain with their sizes updated.
458        let entries: Vec<app::ProjectEntry> = analyze(&root_path, &opts)
459            .map(|pa| app::ProjectEntry {
460                path: pa.project.path.to_string_lossy().into_owned(),
461                type_name: pa.project.type_name(),
462                reclaimable: pa.artifact_size,
463                last_modified: pa.last_modified,
464                artifact_dir_names: pa
465                    .project
466                    .artifact_dirs()
467                    .into_iter()
468                    .map(String::from)
469                    .collect(),
470            })
471            .collect();
472        let _ = tx.send(ProjectScanResult { entries, errors });
473    });
474}
475
476/// Start a background shallow scan (one level only).
477fn start_shallow_scan(
478    tx: &mpsc::Sender<ScanResult>,
479    path: String,
480    apparent: bool,
481    state: &mut app::AppState,
482) {
483    state.loading = true;
484    state.loading_frame = 0;
485    state.loading_message = format!("{}", path);
486
487    let tx = tx.clone();
488    let path_owned = path;
489    std::thread::spawn(move || {
490        let result = do_shallow_scan(&path_owned, apparent);
491        let msg = match result {
492            Ok(root) => {
493                let total_size = root.disk_size;
494                // Flatten in the background thread — no main thread freeze
495                let mut items = Vec::new();
496                app::flatten_disk_item(&root, path_owned.clone(), total_size, 0, None, &mut items);
497                ScanResult {
498                    items: Some(items),
499                    path: path_owned,
500                    total_size,
501                    error: None,
502                }
503            }
504            Err(e) => ScanResult {
505                items: None,
506                path: path_owned,
507                total_size: 0,
508                error: Some(format!("{}", e)),
509            },
510        };
511        let _ = tx.send(msg);
512    });
513}
514
515/// Perform a one-level shallow scan of a directory.
516fn do_shallow_scan(path: &str, apparent: bool) -> Result<DiskItem, Box<dyn Error>> {
517    let target = Path::new(path);
518    let file_info = FileInfo::from_path(target, apparent)?;
519    match file_info {
520        FileInfo::Directory { volume_id } => {
521            DiskItem::from_shallow_scan(target, apparent, volume_id)
522        }
523        _ => Err(format!("{} is not a directory!", path).into()),
524    }
525}