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
21struct ScanResult {
23 items: Option<Vec<app::FlatItem>>,
25 path: String,
26 total_size: u64,
27 #[allow(dead_code)]
30 error: Option<String>,
31}
32
33struct ProjectScanResult {
35 entries: Vec<app::ProjectEntry>,
36 errors: Vec<String>,
38}
39
40#[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
65pub 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 if state.loading {
89 start_shallow_scan(&scan_tx, state.root_path.clone(), state.apparent, state);
90 }
91
92 loop {
93 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 } else {
104 state.loading_frame = state.loading_frame.wrapping_add(1);
105 }
106 }
107
108 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 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 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 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 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 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 state.error_message = None;
258
259 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 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 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
388fn 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
427fn 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 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
476fn 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 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
515fn 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}