1mod commands;
4mod constants;
5mod deletion;
6mod navigation;
7mod render;
8pub mod state;
9mod scanning;
10
11use std::collections::HashSet;
12use std::path::PathBuf;
13use std::time::Duration;
14
15use crossterm::event::{Event, EventStream};
16use futures::StreamExt;
17use ratatui::buffer::Buffer;
18use ratatui::layout::Rect;
19use ratatui::widgets::Widget;
20use ratatui::{DefaultTerminal, Frame};
21use tokio::sync::mpsc;
22
23use gravityfile_analyze::{AgeReport, DuplicateReport};
24use gravityfile_core::FileTree;
25use gravityfile_scan::ScanProgress;
26
27use crate::event::KeyAction;
28use crate::theme::Theme;
29use crate::ui::{TreeState, TreeView};
30
31use self::commands::{parse_command, CommandAction, CommandInput, CommandKeyResult, ThemeCommand};
32use self::constants::{PAGE_SIZE, TICK_INTERVAL_MS};
33use self::render::{render_app, RenderContext};
34use self::state::{AppMode, DeletionProgress, ScanResult, ScanView, SelectedInfo, View};
35
36pub type AppResult<T> = color_eyre::Result<T>;
38
39pub struct App {
41 path: PathBuf,
43 view_root: PathBuf,
45 view_history: Vec<(PathBuf, usize, HashSet<PathBuf>)>,
47 mode: AppMode,
49 view: View,
51 scan_view: ScanView,
53 theme: Theme,
55 tree: Option<FileTree>,
57 tree_state: TreeState,
59 cached_tree_len: usize,
61 duplicates: Option<DuplicateReport>,
63 age_report: Option<AgeReport>,
65 selected_dup_group: usize,
67 selected_stale_dir: usize,
69 show_details: bool,
71 error: Option<String>,
73 marked_for_deletion: HashSet<PathBuf>,
75 deletion_message: Option<(bool, String)>,
77 deletion_progress: Option<DeletionProgress>,
79 scan_progress: Option<ScanProgress>,
81 scan_rx: Option<mpsc::Receiver<ScanResult>>,
83 warnings: Vec<gravityfile_core::ScanWarning>,
85 selected_warning: usize,
87 analyzing: bool,
89 needs_redraw: bool,
91 command_input: CommandInput,
93}
94
95impl App {
96 pub fn new(path: PathBuf) -> Self {
98 Self {
99 path: path.clone(),
100 view_root: path.clone(),
101 view_history: Vec::new(),
102 mode: AppMode::default(),
103 view: View::default(),
104 scan_view: ScanView::default(),
105 theme: Theme::dark(),
106 tree: None,
107 tree_state: TreeState::new(path),
108 cached_tree_len: 0,
109 duplicates: None,
110 age_report: None,
111 selected_dup_group: 0,
112 selected_stale_dir: 0,
113 show_details: true,
114 error: None,
115 marked_for_deletion: HashSet::new(),
116 deletion_message: None,
117 deletion_progress: None,
118 scan_progress: None,
119 scan_rx: None,
120 warnings: Vec::new(),
121 selected_warning: 0,
122 analyzing: false,
123 needs_redraw: true,
124 command_input: CommandInput::new(),
125 }
126 }
127
128 pub async fn run(mut self, mut terminal: DefaultTerminal) -> AppResult<()> {
130 self.start_scan();
132
133 let period = Duration::from_millis(TICK_INTERVAL_MS);
134 let mut interval = tokio::time::interval(period);
135 let mut events = EventStream::new();
136
137 while self.mode != AppMode::Quit {
138 if self.needs_redraw {
139 terminal.draw(|frame| self.render(frame))?;
140 self.needs_redraw = false;
141 }
142
143 tokio::select! {
144 biased;
145
146 Some(Ok(event)) = events.next() => {
147 if let Event::Key(key_event) = event {
148 if key_event.kind == crossterm::event::KeyEventKind::Press {
149 if self.mode == AppMode::Command {
150 self.handle_command_input(key_event);
151 } else {
152 let action = KeyAction::from_key_event(key_event);
153 self.handle_action(action);
154 }
155 }
156 }
157
158 while crossterm::event::poll(Duration::ZERO)? {
160 if let Ok(Event::Key(key_event)) = crossterm::event::read() {
161 if key_event.kind == crossterm::event::KeyEventKind::Press {
162 if self.mode == AppMode::Command {
163 self.handle_command_input(key_event);
164 } else {
165 let action = KeyAction::from_key_event(key_event);
166 self.handle_action(action);
167 }
168 if self.mode == AppMode::Quit {
169 break;
170 }
171 }
172 }
173 }
174 self.needs_redraw = true;
175 }
176
177 Some(result) = async {
178 if let Some(rx) = &mut self.scan_rx {
179 rx.recv().await
180 } else {
181 std::future::pending().await
182 }
183 } => {
184 self.handle_scan_result(result);
185 self.needs_redraw = true;
186 }
187
188 _ = interval.tick() => {
189 }
191 }
192 }
193
194 Ok(())
195 }
196
197 fn start_scan(&mut self) {
199 self.mode = AppMode::Scanning;
200 self.scan_view = ScanView::Progress;
201 self.scan_progress = Some(ScanProgress::new());
202 self.tree = None;
203 self.duplicates = None;
204 self.age_report = None;
205 self.warnings.clear();
206 self.selected_warning = 0;
207 self.cached_tree_len = 0;
208
209 self.scan_rx = Some(scanning::start_scan(self.path.clone()));
210 }
211
212 fn handle_scan_result(&mut self, result: ScanResult) {
214 match result {
215 ScanResult::Progress(progress) => {
216 self.scan_progress = Some(progress);
217 }
218 ScanResult::Warning(warning) => {
219 self.warnings.push(warning);
220 }
221 ScanResult::ScanComplete(Ok(tree)) => {
222 let root_path = tree.root_path.clone();
223
224 for warning in &tree.warnings {
226 if !self.warnings.iter().any(|w| w.path == warning.path) {
227 self.warnings.push(warning.clone());
228 }
229 }
230
231 let tree_for_analysis = tree.clone();
232
233 self.tree = Some(tree);
234 self.tree_state = TreeState::new(root_path);
235 self.update_cached_tree_len();
236 self.mode = AppMode::Normal;
237 self.error = None;
238 self.scan_progress = None;
239
240 self.analyzing = true;
242 self.scan_rx = Some(scanning::start_analysis(tree_for_analysis));
243 }
244 ScanResult::ScanComplete(Err(e)) => {
245 self.error = Some(e.to_string());
246 self.mode = AppMode::Normal;
247 self.scan_progress = None;
248 self.scan_rx = None;
249 self.analyzing = false;
250 }
251 ScanResult::AnalysisComplete {
252 duplicates,
253 age_report,
254 } => {
255 self.duplicates = Some(duplicates);
256 self.age_report = Some(age_report);
257 self.analyzing = false;
258 self.scan_rx = None;
259 }
260 ScanResult::DeletionProgress(progress) => {
261 self.deletion_progress = Some(progress);
262 }
263 ScanResult::DeletionComplete {
264 deleted,
265 failed,
266 bytes_freed,
267 } => {
268 self.deletion_progress = None;
269 self.scan_rx = None;
270
271 let (success, msg) = deletion::format_deletion_result(deleted, failed, bytes_freed);
272 self.deletion_message = Some((success, msg));
273 self.mode = AppMode::Normal;
274
275 self.start_scan();
277 }
278 }
279 }
280
281 fn update_cached_tree_len(&mut self) {
283 if let Some((node, root_path)) = self.get_view_root_node() {
284 let items = TreeView::new(node, &root_path, &self.theme).flatten(&self.tree_state);
285 self.cached_tree_len = items.len();
286 } else {
287 self.cached_tree_len = 0;
288 }
289 }
290
291 fn render(&self, frame: &mut Frame) {
293 frame.render_widget(self, frame.area());
294 }
295
296 fn handle_action(&mut self, action: KeyAction) {
298 self.deletion_message = None;
300
301 match self.mode {
303 AppMode::Help => {
304 match action {
305 KeyAction::ToggleHelp | KeyAction::Quit => {
306 if self.scan_progress.is_some() && self.tree.is_none() {
307 self.mode = AppMode::Scanning;
308 } else {
309 self.mode = AppMode::Normal;
310 }
311 }
312 _ => {}
313 }
314 return;
315 }
316 AppMode::ConfirmDelete => {
317 match action {
318 KeyAction::Confirm | KeyAction::DrillDown => {
319 self.execute_deletion();
320 }
321 KeyAction::Quit | KeyAction::Cancel => {
322 self.mode = AppMode::Normal;
323 }
324 _ => {}
325 }
326 return;
327 }
328 AppMode::Deleting => {
329 return;
330 }
331 AppMode::Command => {
332 match action {
333 KeyAction::Quit | KeyAction::Cancel => {
334 self.command_input.clear();
335 self.mode = AppMode::Normal;
336 }
337 _ => {}
338 }
339 return;
340 }
341 _ => {}
342 }
343
344 match action {
345 KeyAction::Quit | KeyAction::ForceQuit => {
346 self.mode = AppMode::Quit;
347 }
348
349 KeyAction::NextTab => {
350 if self.mode == AppMode::Scanning {
351 self.scan_view = self.scan_view.next();
352 } else {
353 self.view = self.view.next();
354 }
355 }
356 KeyAction::PrevTab => {
357 if self.mode == AppMode::Scanning {
358 self.scan_view = self.scan_view.prev();
359 } else {
360 self.view = self.view.prev();
361 }
362 }
363
364 KeyAction::MoveUp => self.move_up(),
365 KeyAction::MoveDown => self.move_down(),
366 KeyAction::PageUp => self.page_up(),
367 KeyAction::PageDown => self.page_down(),
368 KeyAction::JumpToTop => self.jump_to_top(),
369 KeyAction::JumpToBottom => self.jump_to_bottom(),
370
371 KeyAction::MoveRight | KeyAction::Expand => {
372 if self.view == View::Explorer {
373 self.expand_selected();
374 }
375 }
376 KeyAction::MoveLeft | KeyAction::Collapse => {
377 if self.view == View::Explorer {
378 self.collapse_selected();
379 }
380 }
381 KeyAction::ToggleExpand => {
382 if self.view == View::Explorer {
383 self.toggle_selected();
384 }
385 }
386
387 KeyAction::Delete => {
388 self.toggle_mark_for_deletion();
389 }
390 KeyAction::Confirm => {
391 if !self.marked_for_deletion.is_empty() {
392 self.mode = AppMode::ConfirmDelete;
393 } else if self.view == View::Explorer {
394 self.toggle_selected();
395 }
396 }
397 KeyAction::ClearMarks => {
398 self.marked_for_deletion.clear();
399 }
400
401 KeyAction::DrillDown => {
402 if !self.marked_for_deletion.is_empty() {
403 self.mode = AppMode::ConfirmDelete;
404 } else if self.view == View::Explorer {
405 self.drill_into_selected();
406 }
407 }
408 KeyAction::NavigateBack => {
409 if self.view == View::Explorer {
410 self.navigate_back();
411 }
412 }
413
414 KeyAction::CommandMode => {
415 self.command_input.clear();
416 self.mode = AppMode::Command;
417 }
418
419 KeyAction::ToggleDetails => {
420 self.show_details = !self.show_details;
421 }
422 KeyAction::ToggleHelp => {
423 self.mode = AppMode::Help;
424 }
425 KeyAction::ToggleTheme => {
426 self.theme = self.theme.toggle();
427 }
428 KeyAction::Refresh => {
429 self.marked_for_deletion.clear();
430 self.start_scan();
431 }
432
433 _ => {}
434 }
435 }
436
437 fn toggle_mark_for_deletion(&mut self) {
439 let path = match self.view {
440 View::Explorer => {
441 if let Some((node, root_path)) = self.get_view_root_node() {
442 let items =
443 TreeView::new(node, &root_path, &self.theme).flatten(&self.tree_state);
444 items.get(self.tree_state.selected).map(|i| i.path.clone())
445 } else {
446 None
447 }
448 }
449 View::Duplicates => {
450 if let Some((filtered, _)) = self.get_filtered_duplicates() {
451 let selected = self.selected_dup_group.min(filtered.len().saturating_sub(1));
452 filtered.get(selected).and_then(|g| {
453 g.paths
454 .iter()
455 .find(|p| p.starts_with(&self.view_root))
456 .cloned()
457 })
458 } else {
459 None
460 }
461 }
462 View::Age => {
463 if let Some(filtered) = self.get_filtered_stale_dirs() {
464 let selected = self.selected_stale_dir.min(filtered.len().saturating_sub(1));
465 filtered.get(selected).map(|d| d.path.clone())
466 } else {
467 None
468 }
469 }
470 View::Errors => None,
471 };
472
473 if let Some(path) = path {
474 if path != self.path {
475 if self.marked_for_deletion.contains(&path) {
476 self.marked_for_deletion.remove(&path);
477 } else {
478 self.marked_for_deletion.insert(path);
479 }
480 }
481 }
482 }
483
484 fn execute_deletion(&mut self) {
486 let paths_with_sizes: Vec<(PathBuf, u64)> = self
487 .marked_for_deletion
488 .iter()
489 .map(|p| (p.clone(), self.get_path_size(p).unwrap_or(0)))
490 .collect();
491
492 let total = paths_with_sizes.len();
493 if total == 0 {
494 return;
495 }
496
497 self.marked_for_deletion.clear();
498 self.mode = AppMode::Deleting;
499 self.deletion_progress = Some(DeletionProgress {
500 total,
501 deleted: 0,
502 failed: 0,
503 bytes_freed: 0,
504 current: paths_with_sizes.first().map(|(p, _)| p.clone()),
505 });
506
507 self.scan_rx = Some(deletion::start_deletion(paths_with_sizes));
508 }
509
510 fn get_path_size(&self, target: &PathBuf) -> Option<u64> {
512 let tree = self.tree.as_ref()?;
513
514 fn find_size(
515 node: &gravityfile_core::FileNode,
516 target: &PathBuf,
517 current: &PathBuf,
518 ) -> Option<u64> {
519 if current == target {
520 return Some(node.size);
521 }
522 for child in &node.children {
523 let child_path = current.join(&*child.name);
524 if let Some(size) = find_size(child, target, &child_path) {
525 return Some(size);
526 }
527 }
528 None
529 }
530
531 find_size(&tree.root, target, &tree.root_path)
532 }
533
534 fn move_up(&mut self) {
536 match self.view {
537 View::Explorer => self.tree_state.move_up(1),
538 View::Duplicates => {
539 self.selected_dup_group = self.selected_dup_group.saturating_sub(1);
540 }
541 View::Age => {
542 self.selected_stale_dir = self.selected_stale_dir.saturating_sub(1);
543 }
544 View::Errors => {
545 self.selected_warning = self.selected_warning.saturating_sub(1);
546 }
547 }
548 }
549
550 fn move_down(&mut self) {
551 match self.view {
552 View::Explorer => {
553 self.tree_state.move_down(1, self.cached_tree_len);
554 }
555 View::Duplicates => {
556 if let Some((filtered, _)) = self.get_filtered_duplicates() {
557 let max = filtered.len().saturating_sub(1);
558 self.selected_dup_group = (self.selected_dup_group + 1).min(max);
559 }
560 }
561 View::Age => {
562 if let Some(filtered) = self.get_filtered_stale_dirs() {
563 let max = filtered.len().saturating_sub(1);
564 self.selected_stale_dir = (self.selected_stale_dir + 1).min(max);
565 }
566 }
567 View::Errors => {
568 let max = self.warnings.len().saturating_sub(1);
569 self.selected_warning = (self.selected_warning + 1).min(max);
570 }
571 }
572 }
573
574 fn page_up(&mut self) {
575 match self.view {
576 View::Explorer => self.tree_state.move_up(PAGE_SIZE),
577 View::Duplicates => {
578 self.selected_dup_group = self.selected_dup_group.saturating_sub(PAGE_SIZE);
579 }
580 View::Age => {
581 self.selected_stale_dir = self.selected_stale_dir.saturating_sub(PAGE_SIZE);
582 }
583 View::Errors => {
584 self.selected_warning = self.selected_warning.saturating_sub(PAGE_SIZE);
585 }
586 }
587 }
588
589 fn page_down(&mut self) {
590 match self.view {
591 View::Explorer => {
592 self.tree_state.move_down(PAGE_SIZE, self.cached_tree_len);
593 }
594 View::Duplicates => {
595 if let Some((filtered, _)) = self.get_filtered_duplicates() {
596 let max = filtered.len().saturating_sub(1);
597 self.selected_dup_group = (self.selected_dup_group + PAGE_SIZE).min(max);
598 }
599 }
600 View::Age => {
601 if let Some(filtered) = self.get_filtered_stale_dirs() {
602 let max = filtered.len().saturating_sub(1);
603 self.selected_stale_dir = (self.selected_stale_dir + PAGE_SIZE).min(max);
604 }
605 }
606 View::Errors => {
607 let max = self.warnings.len().saturating_sub(1);
608 self.selected_warning = (self.selected_warning + PAGE_SIZE).min(max);
609 }
610 }
611 }
612
613 fn jump_to_top(&mut self) {
614 match self.view {
615 View::Explorer => self.tree_state.jump_to_top(),
616 View::Duplicates => self.selected_dup_group = 0,
617 View::Age => self.selected_stale_dir = 0,
618 View::Errors => self.selected_warning = 0,
619 }
620 }
621
622 fn jump_to_bottom(&mut self) {
623 match self.view {
624 View::Explorer => {
625 self.tree_state.jump_to_bottom(self.cached_tree_len);
626 }
627 View::Duplicates => {
628 if let Some((filtered, _)) = self.get_filtered_duplicates() {
629 self.selected_dup_group = filtered.len().saturating_sub(1);
630 }
631 }
632 View::Age => {
633 if let Some(filtered) = self.get_filtered_stale_dirs() {
634 self.selected_stale_dir = filtered.len().saturating_sub(1);
635 }
636 }
637 View::Errors => {
638 self.selected_warning = self.warnings.len().saturating_sub(1);
639 }
640 }
641 }
642
643 fn expand_selected(&mut self) {
644 if let Some((node, root_path)) = self.get_view_root_node() {
645 let items = TreeView::new(node, &root_path, &self.theme).flatten(&self.tree_state);
646 if let Some(item) = items.get(self.tree_state.selected) {
647 self.tree_state.expand(&item.path);
648 self.update_cached_tree_len();
649 }
650 }
651 }
652
653 fn collapse_selected(&mut self) {
654 if let Some((node, root_path)) = self.get_view_root_node() {
655 let items = TreeView::new(node, &root_path, &self.theme).flatten(&self.tree_state);
656 if let Some(item) = items.get(self.tree_state.selected) {
657 self.tree_state.collapse(&item.path);
658 self.update_cached_tree_len();
659 }
660 }
661 }
662
663 fn toggle_selected(&mut self) {
664 if let Some((node, root_path)) = self.get_view_root_node() {
665 let items = TreeView::new(node, &root_path, &self.theme).flatten(&self.tree_state);
666 if let Some(item) = items.get(self.tree_state.selected) {
667 self.tree_state.toggle_expand(&item.path);
668 self.update_cached_tree_len();
669 }
670 }
671 }
672
673 fn get_selected_info(&self) -> Option<SelectedInfo> {
674 let tree = self.tree.as_ref()?;
675 let (view_node, view_path) = self.get_view_root_node()?;
676 let items = TreeView::new(view_node, &view_path, &self.theme).flatten(&self.tree_state);
677 let item = items.get(self.tree_state.selected)?;
678
679 let node = Self::find_node_at_path(&tree.root, &item.path, &tree.root_path)?;
680
681 Some(SelectedInfo {
682 name: item.node.name.clone(),
683 path: item.path.clone(),
684 size: node.size,
685 file_count: node.file_count(),
686 dir_count: node.dir_count(),
687 modified: node.timestamps.modified,
688 is_dir: node.is_dir(),
689 })
690 }
691
692 fn drill_into_selected(&mut self) {
694 let Some((view_node, view_path)) = self.get_view_root_node() else {
695 return;
696 };
697
698 let items = TreeView::new(view_node, &view_path, &self.theme).flatten(&self.tree_state);
699 let Some(item) = items.get(self.tree_state.selected) else {
700 return;
701 };
702
703 if !matches!(
704 item.node.kind,
705 crate::ui::VisibleNodeKind::Directory { .. }
706 ) {
707 return;
708 }
709
710 if item.path == self.view_root {
711 self.tree_state.expand(&item.path);
712 self.update_cached_tree_len();
713 return;
714 }
715
716 let saved_expanded = self.tree_state.expanded.clone();
717 let saved_selected = self.tree_state.selected;
718 self.view_history
719 .push((self.view_root.clone(), saved_selected, saved_expanded));
720
721 self.view_root = item.path.clone();
722
723 self.tree_state.selected = 0;
724 self.tree_state.offset = 0;
725 self.tree_state.expand(&self.view_root);
726 self.update_cached_tree_len();
727 }
728
729 fn navigate_back(&mut self) {
731 if let Some((prev_root, saved_selected, saved_expanded)) = self.view_history.pop() {
732 self.view_root = prev_root;
733 self.tree_state.expanded = saved_expanded;
734 self.tree_state.selected = saved_selected;
735 self.tree_state.offset = 0;
736 self.update_cached_tree_len();
737 } else if self.view_root != self.path {
738 if let Some(parent) = self.view_root.parent() {
739 if parent.starts_with(&self.path) || parent == self.path {
740 let dir_we_drilled_into = self.view_root.clone();
741 self.tree_state.collapse(&dir_we_drilled_into);
742
743 self.view_root = parent.to_path_buf();
744 self.tree_state.expand(&self.view_root);
745 self.tree_state.selected = 0;
746 self.tree_state.offset = 0;
747 self.update_cached_tree_len();
748 }
749 }
750 }
751 }
752
753 fn find_node_at_path<'a>(
755 node: &'a gravityfile_core::FileNode,
756 target_path: &PathBuf,
757 current_path: &PathBuf,
758 ) -> Option<&'a gravityfile_core::FileNode> {
759 if current_path == target_path {
760 return Some(node);
761 }
762 for child in &node.children {
763 let child_path = current_path.join(&*child.name);
764 if let Some(found) = Self::find_node_at_path(child, target_path, &child_path) {
765 return Some(found);
766 }
767 }
768 None
769 }
770
771 fn get_view_root_node(&self) -> Option<(&gravityfile_core::FileNode, PathBuf)> {
773 let tree = self.tree.as_ref()?;
774 let node = Self::find_node_at_path(&tree.root, &self.view_root, &tree.root_path)?;
775 Some((node, self.view_root.clone()))
776 }
777
778 fn handle_command_input(&mut self, key: crossterm::event::KeyEvent) {
780 match self.command_input.handle_key(key) {
781 CommandKeyResult::Continue => {}
782 CommandKeyResult::Cancel => {
783 self.mode = AppMode::Normal;
784 }
785 CommandKeyResult::Execute(cmd) => {
786 self.mode = AppMode::Normal;
787 self.execute_command(&cmd);
788 }
789 }
790 }
791
792 fn execute_command(&mut self, cmd: &str) {
794 match parse_command(cmd) {
795 CommandAction::None => {}
796 CommandAction::Quit => {
797 self.mode = AppMode::Quit;
798 }
799 CommandAction::Refresh => {
800 self.marked_for_deletion.clear();
801 self.start_scan();
802 }
803 CommandAction::NavigateTo(target) => {
804 self.cd_to_path(&target);
805 }
806 CommandAction::GoToRoot => {
807 self.go_to_root();
808 }
809 CommandAction::NavigateBack => {
810 self.navigate_back();
811 }
812 CommandAction::ShowHelp => {
813 self.mode = AppMode::Help;
814 }
815 CommandAction::SwitchView(view) => {
816 self.view = view;
817 }
818 CommandAction::ClearMarks => {
819 self.marked_for_deletion.clear();
820 }
821 CommandAction::ToggleDetails => {
822 self.show_details = !self.show_details;
823 }
824 CommandAction::SetTheme(theme_cmd) => match theme_cmd {
825 ThemeCommand::Dark => self.theme = Theme::dark(),
826 ThemeCommand::Light => self.theme = Theme::light(),
827 ThemeCommand::Toggle => self.theme = self.theme.toggle(),
828 },
829 }
830 }
831
832 fn go_to_root(&mut self) {
834 self.view_root = self.path.clone();
835 self.view_history.clear();
836 self.tree_state.selected = 0;
837 self.tree_state.offset = 0;
838 self.tree_state.expand(&self.view_root);
839 self.update_cached_tree_len();
840 }
841
842 fn cd_to_path(&mut self, target: &str) {
844 if target == "/" || target == "~" {
845 self.go_to_root();
846 return;
847 }
848 if target == ".." {
849 self.navigate_back();
850 return;
851 }
852
853 let Some(tree) = &self.tree else {
854 return;
855 };
856
857 let target_path = if target.starts_with('/') {
858 let relative = target.trim_start_matches('/');
859 self.path.join(relative)
860 } else {
861 self.view_root.join(target)
862 };
863
864 if let Some(node) = Self::find_node_at_path(&tree.root, &target_path, &tree.root_path) {
865 if node.is_dir() {
866 let saved_expanded = self.tree_state.expanded.clone();
867 let saved_selected = self.tree_state.selected;
868 self.view_history
869 .push((self.view_root.clone(), saved_selected, saved_expanded));
870
871 self.view_root = target_path;
872 self.tree_state.selected = 0;
873 self.tree_state.offset = 0;
874 self.tree_state.expand(&self.view_root);
875 self.update_cached_tree_len();
876 }
877 }
878 }
879
880 pub fn is_marked(&self, path: &PathBuf) -> bool {
882 self.marked_for_deletion.contains(path)
883 }
884
885 fn get_filtered_duplicates(
887 &self,
888 ) -> Option<(Vec<&gravityfile_analyze::DuplicateGroup>, u64)> {
889 let dups = self.duplicates.as_ref()?;
890
891 if self.view_root == self.path {
892 let total: u64 = dups.groups.iter().map(|g| g.wasted_bytes).sum();
893 return Some((dups.groups.iter().collect(), total));
894 }
895
896 let filtered: Vec<&gravityfile_analyze::DuplicateGroup> = dups
897 .groups
898 .iter()
899 .filter(|g| g.paths.iter().any(|p| p.starts_with(&self.view_root)))
900 .collect();
901
902 let total_wasted: u64 = filtered
903 .iter()
904 .map(|g| {
905 let paths_in_view = g
906 .paths
907 .iter()
908 .filter(|p| p.starts_with(&self.view_root))
909 .count();
910 if paths_in_view > 1 {
911 g.size * (paths_in_view as u64 - 1)
912 } else {
913 0
914 }
915 })
916 .sum();
917
918 Some((filtered, total_wasted))
919 }
920
921 fn get_filtered_stale_dirs(&self) -> Option<Vec<&gravityfile_analyze::StaleDirectory>> {
923 let age = self.age_report.as_ref()?;
924
925 if self.view_root == self.path {
926 return Some(age.stale_directories.iter().collect());
927 }
928
929 let filtered: Vec<&gravityfile_analyze::StaleDirectory> = age
930 .stale_directories
931 .iter()
932 .filter(|d| d.path.starts_with(&self.view_root))
933 .collect();
934
935 Some(filtered)
936 }
937}
938
939impl Widget for &App {
940 fn render(self, area: Rect, buf: &mut Buffer) {
941 let ctx = RenderContext {
942 mode: self.mode,
943 view: self.view,
944 scan_view: self.scan_view,
945 theme: &self.theme,
946 path: &self.path,
947 view_root: &self.view_root,
948 show_details: self.show_details,
949 tree: self.tree.as_ref(),
950 tree_state: &self.tree_state,
951 scan_progress: self.scan_progress.as_ref(),
952 deletion_progress: self.deletion_progress.as_ref(),
953 duplicates: self.duplicates.as_ref(),
954 age_report: self.age_report.as_ref(),
955 warnings: &self.warnings,
956 selected_dup_group: self.selected_dup_group,
957 selected_stale_dir: self.selected_stale_dir,
958 selected_warning: self.selected_warning,
959 marked_for_deletion: &self.marked_for_deletion,
960 deletion_message: self.deletion_message.as_ref(),
961 error: self.error.as_deref(),
962 command_input: self.command_input.buffer(),
963 command_cursor: self.command_input.cursor(),
964 get_path_size: Box::new(|p| self.get_path_size(p)),
965 get_selected_info: self.get_selected_info(),
966 get_view_root_node: self.get_view_root_node(),
967 get_filtered_duplicates: self.get_filtered_duplicates(),
968 get_filtered_stale_dirs: self.get_filtered_stale_dirs(),
969 };
970
971 render_app(&ctx, area, buf);
972 }
973}