1use std::collections::HashSet;
4use std::fs;
5use std::path::PathBuf;
6use std::time::Duration;
7
8use crossterm::event::{Event, EventStream, KeyCode, KeyEvent, KeyModifiers};
9use futures::StreamExt;
10use ratatui::buffer::Buffer;
11use ratatui::layout::{Constraint, Layout, Rect};
12use ratatui::style::{Modifier, Style};
13use ratatui::text::{Line, Span};
14use ratatui::widgets::{Block, Borders, Clear, Paragraph, Tabs, Widget};
15use ratatui::{DefaultTerminal, Frame};
16use strum::{Display, EnumIter, FromRepr, IntoEnumIterator};
17use tokio::sync::mpsc;
18
19use gravityfile_analyze::{
20 AgeAnalyzer, AgeConfig, AgeReport, DuplicateConfig, DuplicateFinder, DuplicateReport,
21};
22use gravityfile_core::FileTree;
23use gravityfile_scan::{JwalkScanner, ScanConfig, ScanProgress};
24
25use crate::event::KeyAction;
26use crate::theme::Theme;
27use crate::ui::{format_relative_time, format_size, AppLayout, HelpOverlay, TreeState, TreeView};
28
29pub type AppResult<T> = color_eyre::Result<T>;
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
34pub enum AppMode {
35 #[default]
36 Normal,
37 Scanning,
38 Help,
39 ConfirmDelete,
41 Command,
43 Quit,
44}
45
46#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Display, EnumIter, FromRepr)]
48pub enum View {
49 #[default]
50 Explorer,
51 Duplicates,
52 Age,
53 Errors,
54}
55
56#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
58pub enum ScanView {
59 #[default]
60 Progress,
61 Errors,
62}
63
64impl ScanView {
65 fn next(self) -> Self {
66 match self {
67 ScanView::Progress => ScanView::Errors,
68 ScanView::Errors => ScanView::Progress,
69 }
70 }
71
72 fn prev(self) -> Self {
73 self.next() }
75}
76
77impl View {
78 fn next(self) -> Self {
79 let current = self as usize;
80 let next = (current + 1) % Self::iter().count();
81 Self::from_repr(next).unwrap_or_default()
82 }
83
84 fn prev(self) -> Self {
85 let current = self as usize;
86 let count = Self::iter().count();
87 let prev = (current + count - 1) % count;
88 Self::from_repr(prev).unwrap_or_default()
89 }
90}
91
92enum ScanResult {
94 Progress(ScanProgress),
95 #[allow(dead_code)] Warning(gravityfile_core::ScanWarning),
97 ScanComplete(Result<FileTree, gravityfile_scan::ScanError>),
98 AnalysisComplete {
99 duplicates: DuplicateReport,
100 age_report: AgeReport,
101 },
102}
103
104pub struct App {
106 path: PathBuf,
108 view_root: PathBuf,
110 view_history: Vec<(PathBuf, usize, HashSet<PathBuf>)>,
112 mode: AppMode,
114 view: View,
116 scan_view: ScanView,
118 theme: Theme,
120 tree: Option<FileTree>,
122 tree_state: TreeState,
124 cached_tree_len: usize,
126 duplicates: Option<DuplicateReport>,
128 age_report: Option<AgeReport>,
130 selected_dup_group: usize,
132 selected_stale_dir: usize,
134 show_details: bool,
136 error: Option<String>,
138 marked_for_deletion: HashSet<PathBuf>,
140 deletion_message: Option<(bool, String)>,
142 scan_progress: Option<ScanProgress>,
144 scan_rx: Option<mpsc::Receiver<ScanResult>>,
146 warnings: Vec<gravityfile_core::ScanWarning>,
148 selected_warning: usize,
150 analyzing: bool,
152 needs_redraw: bool,
154 command_input: String,
156 command_cursor: usize,
158}
159
160impl App {
161 pub fn new(path: PathBuf) -> Self {
163 Self {
164 path: path.clone(),
165 view_root: path.clone(),
166 view_history: Vec::new(),
167 mode: AppMode::default(),
168 view: View::default(),
169 scan_view: ScanView::default(),
170 theme: Theme::dark(),
171 tree: None,
172 tree_state: TreeState::new(path),
173 cached_tree_len: 0,
174 duplicates: None,
175 age_report: None,
176 selected_dup_group: 0,
177 selected_stale_dir: 0,
178 show_details: true,
179 error: None,
180 marked_for_deletion: HashSet::new(),
181 deletion_message: None,
182 scan_progress: None,
183 scan_rx: None,
184 warnings: Vec::new(),
185 selected_warning: 0,
186 analyzing: false,
187 needs_redraw: true,
188 command_input: String::new(),
189 command_cursor: 0,
190 }
191 }
192
193 pub async fn run(mut self, mut terminal: DefaultTerminal) -> AppResult<()> {
195 self.start_scan();
197
198 let period = Duration::from_millis(50);
200 let mut interval = tokio::time::interval(period);
201 let mut events = EventStream::new();
202
203 while self.mode != AppMode::Quit {
205 if self.needs_redraw {
207 terminal.draw(|frame| self.render(frame))?;
208 self.needs_redraw = false;
209 }
210
211 tokio::select! {
212 biased; Some(Ok(event)) = events.next() => {
216 if let Event::Key(key_event) = event {
217 if key_event.kind == crossterm::event::KeyEventKind::Press {
219 if self.mode == AppMode::Command {
221 self.handle_command_input(key_event);
222 } else {
223 let action = KeyAction::from_key_event(key_event);
224 self.handle_action(action);
225 }
226 }
227 }
228
229 while crossterm::event::poll(Duration::ZERO)? {
231 if let Ok(Event::Key(key_event)) = crossterm::event::read() {
232 if key_event.kind == crossterm::event::KeyEventKind::Press {
233 if self.mode == AppMode::Command {
234 self.handle_command_input(key_event);
235 } else {
236 let action = KeyAction::from_key_event(key_event);
237 self.handle_action(action);
238 }
239 if self.mode == AppMode::Quit {
241 break;
242 }
243 }
244 }
245 }
246 self.needs_redraw = true;
247 }
248
249 Some(result) = async {
251 if let Some(rx) = &mut self.scan_rx {
252 rx.recv().await
253 } else {
254 std::future::pending().await
255 }
256 } => {
257 self.handle_scan_result(result);
258 self.needs_redraw = true;
259 }
260
261 _ = interval.tick() => {
263 }
265 }
266 }
267
268 Ok(())
269 }
270
271 fn start_scan(&mut self) {
273 self.mode = AppMode::Scanning;
274 self.scan_view = ScanView::Progress;
275 self.scan_progress = Some(ScanProgress::new());
276 self.tree = None;
277 self.duplicates = None;
278 self.age_report = None;
279 self.warnings.clear();
280 self.selected_warning = 0;
281 self.cached_tree_len = 0;
282
283 let (tx, rx) = mpsc::channel(100);
284 self.scan_rx = Some(rx);
285
286 let path = self.path.clone();
287
288 tokio::spawn(async move {
290 let config = ScanConfig::new(&path);
291 let scanner = JwalkScanner::new();
292 let mut progress_rx = scanner.subscribe();
293
294 let tx_progress = tx.clone();
296 let progress_task = tokio::spawn(async move {
297 while let Ok(progress) = progress_rx.recv().await {
298 if tx_progress.send(ScanResult::Progress(progress)).await.is_err() {
299 break;
300 }
301 }
302 });
303
304 let result = tokio::task::spawn_blocking(move || scanner.scan(&config))
306 .await
307 .unwrap_or_else(|e| Err(gravityfile_scan::ScanError::Other {
308 message: e.to_string(),
309 }));
310
311 progress_task.abort();
313 let _ = tx.send(ScanResult::ScanComplete(result)).await;
314 });
315 }
316
317 fn handle_scan_result(&mut self, result: ScanResult) {
319 match result {
320 ScanResult::Progress(progress) => {
321 self.scan_progress = Some(progress);
322 }
323 ScanResult::Warning(warning) => {
324 self.warnings.push(warning);
326 }
327 ScanResult::ScanComplete(Ok(tree)) => {
328 let root_path = tree.root_path.clone();
329
330 for warning in &tree.warnings {
332 if !self.warnings.iter().any(|w| w.path == warning.path) {
333 self.warnings.push(warning.clone());
334 }
335 }
336
337 let tree_for_analysis = tree.clone();
339
340 self.tree = Some(tree);
341 self.tree_state = TreeState::new(root_path);
342 self.update_cached_tree_len();
343 self.mode = AppMode::Normal;
344 self.error = None;
345 self.scan_progress = None;
346
347 self.analyzing = true;
349 self.start_analysis(tree_for_analysis);
350 }
351 ScanResult::ScanComplete(Err(e)) => {
352 self.error = Some(e.to_string());
353 self.mode = AppMode::Normal;
354 self.scan_progress = None;
355 self.scan_rx = None;
356 self.analyzing = false;
357 }
358 ScanResult::AnalysisComplete { duplicates, age_report } => {
359 self.duplicates = Some(duplicates);
360 self.age_report = Some(age_report);
361 self.analyzing = false;
362 self.scan_rx = None;
363 }
364 }
365 }
366
367 fn update_cached_tree_len(&mut self) {
369 if let Some((node, root_path)) = self.get_view_root_node() {
370 let items = TreeView::new(node, &root_path, &self.theme)
371 .flatten(&self.tree_state);
372 self.cached_tree_len = items.len();
373 } else {
374 self.cached_tree_len = 0;
375 }
376 }
377
378 fn start_analysis(&mut self, tree: FileTree) {
380 let (tx, rx) = mpsc::channel(10);
382 self.scan_rx = Some(rx);
383
384 tokio::spawn(async move {
385 let result = tokio::task::spawn_blocking(move || {
387 let dup_config = DuplicateConfig::builder()
388 .min_size(1024u64)
389 .max_groups(100usize)
390 .build()
391 .unwrap();
392 let finder = DuplicateFinder::with_config(dup_config);
393 let duplicates = finder.find_duplicates(&tree);
394
395 let age_config = AgeConfig::default();
396 let analyzer = AgeAnalyzer::with_config(age_config);
397 let age_report = analyzer.analyze(&tree);
398
399 (duplicates, age_report)
400 })
401 .await;
402
403 if let Ok((duplicates, age_report)) = result {
404 let _ = tx.send(ScanResult::AnalysisComplete { duplicates, age_report }).await;
405 }
406 });
407 }
408
409 fn render(&self, frame: &mut Frame) {
411 frame.render_widget(self, frame.area());
412 }
413
414 fn handle_action(&mut self, action: KeyAction) {
416 self.deletion_message = None;
418
419 match self.mode {
421 AppMode::Help => {
422 match action {
423 KeyAction::ToggleHelp | KeyAction::Quit => {
424 if self.scan_progress.is_some() && self.tree.is_none() {
426 self.mode = AppMode::Scanning;
427 } else {
428 self.mode = AppMode::Normal;
429 }
430 }
431 _ => {}
432 }
433 return;
434 }
435 AppMode::ConfirmDelete => {
436 match action {
437 KeyAction::Confirm => {
438 self.execute_deletion();
439 self.mode = AppMode::Normal;
440 }
441 KeyAction::Quit | KeyAction::Cancel => {
442 self.mode = AppMode::Normal;
443 }
444 _ => {}
445 }
446 return;
447 }
448 AppMode::Command => {
449 match action {
452 KeyAction::Quit | KeyAction::Cancel => {
453 self.command_input.clear();
454 self.command_cursor = 0;
455 self.mode = AppMode::Normal;
456 }
457 _ => {}
458 }
459 return;
460 }
461 _ => {}
462 }
463
464 match action {
465 KeyAction::Quit | KeyAction::ForceQuit => {
466 self.mode = AppMode::Quit;
467 }
468
469 KeyAction::NextTab => {
471 if self.mode == AppMode::Scanning {
472 self.scan_view = self.scan_view.next();
473 } else {
474 self.view = self.view.next();
475 }
476 }
477 KeyAction::PrevTab => {
478 if self.mode == AppMode::Scanning {
479 self.scan_view = self.scan_view.prev();
480 } else {
481 self.view = self.view.prev();
482 }
483 }
484
485 KeyAction::MoveUp => self.move_up(),
487 KeyAction::MoveDown => self.move_down(),
488 KeyAction::PageUp => self.page_up(),
489 KeyAction::PageDown => self.page_down(),
490 KeyAction::JumpToTop => self.jump_to_top(),
491 KeyAction::JumpToBottom => self.jump_to_bottom(),
492
493 KeyAction::MoveRight | KeyAction::Expand => {
494 if self.view == View::Explorer {
495 self.expand_selected();
496 }
497 }
498 KeyAction::MoveLeft | KeyAction::Collapse => {
499 if self.view == View::Explorer {
500 self.collapse_selected();
501 }
502 }
503 KeyAction::ToggleExpand => {
504 if self.view == View::Explorer {
505 self.toggle_selected();
506 }
507 }
508
509 KeyAction::Delete => {
511 self.toggle_mark_for_deletion();
512 }
513 KeyAction::Confirm => {
514 if !self.marked_for_deletion.is_empty() {
516 self.mode = AppMode::ConfirmDelete;
517 } else if self.view == View::Explorer {
518 self.toggle_selected();
519 }
520 }
521 KeyAction::ClearMarks => {
522 self.marked_for_deletion.clear();
523 }
524
525 KeyAction::DrillDown => {
527 if self.view == View::Explorer {
528 self.drill_into_selected();
529 }
530 }
531 KeyAction::NavigateBack => {
532 if self.view == View::Explorer {
533 self.navigate_back();
534 }
535 }
536
537 KeyAction::CommandMode => {
539 self.command_input.clear();
540 self.command_cursor = 0;
541 self.mode = AppMode::Command;
542 }
543
544 KeyAction::ToggleDetails => {
545 self.show_details = !self.show_details;
546 }
547 KeyAction::ToggleHelp => {
548 self.mode = AppMode::Help;
549 }
550 KeyAction::ToggleTheme => {
551 self.theme = self.theme.toggle();
552 }
553 KeyAction::Refresh => {
554 self.marked_for_deletion.clear();
555 self.start_scan();
556 }
557
558 _ => {}
559 }
560 }
561
562 fn toggle_mark_for_deletion(&mut self) {
564 let path = match self.view {
565 View::Explorer => {
566 if let Some((node, root_path)) = self.get_view_root_node() {
567 let items = TreeView::new(node, &root_path, &self.theme)
568 .flatten(&self.tree_state);
569 items.get(self.tree_state.selected).map(|i| i.path.clone())
570 } else {
571 None
572 }
573 }
574 View::Duplicates => {
575 if let Some(dups) = &self.duplicates {
576 dups.groups
577 .get(self.selected_dup_group)
578 .and_then(|g| g.paths.first().cloned())
579 } else {
580 None
581 }
582 }
583 View::Age => {
584 if let Some(age) = &self.age_report {
585 age.stale_directories
586 .get(self.selected_stale_dir)
587 .map(|d| d.path.clone())
588 } else {
589 None
590 }
591 }
592 View::Errors => {
593 None
595 }
596 };
597
598 if let Some(path) = path {
599 if path != self.path {
601 if self.marked_for_deletion.contains(&path) {
602 self.marked_for_deletion.remove(&path);
603 } else {
604 self.marked_for_deletion.insert(path);
605 }
606 }
607 }
608 }
609
610 fn execute_deletion(&mut self) {
612 let mut deleted = 0;
613 let mut failed = 0;
614 let mut bytes_freed: u64 = 0;
615
616 let paths_with_sizes: Vec<(PathBuf, u64)> = self
618 .marked_for_deletion
619 .iter()
620 .map(|p| (p.clone(), self.get_path_size(p).unwrap_or(0)))
621 .collect();
622
623 self.marked_for_deletion.clear();
624
625 for (path, size) in paths_with_sizes {
626 let result = if path.is_dir() {
627 fs::remove_dir_all(&path)
628 } else {
629 fs::remove_file(&path)
630 };
631
632 match result {
633 Ok(()) => {
634 deleted += 1;
635 bytes_freed += size;
636 }
637 Err(_) => {
638 failed += 1;
639 }
640 }
641 }
642
643 let msg = if failed == 0 {
644 format!(
645 "Deleted {} items, freed {}",
646 deleted,
647 format_size(bytes_freed)
648 )
649 } else {
650 format!(
651 "Deleted {}, failed {} (freed {})",
652 deleted,
653 failed,
654 format_size(bytes_freed)
655 )
656 };
657
658 self.deletion_message = Some((failed == 0, msg));
659
660 self.start_scan();
662 }
663
664 fn get_path_size(&self, target: &PathBuf) -> Option<u64> {
666 let tree = self.tree.as_ref()?;
667
668 fn find_size(
669 node: &gravityfile_core::FileNode,
670 target: &PathBuf,
671 current: &PathBuf,
672 ) -> Option<u64> {
673 if current == target {
674 return Some(node.size);
675 }
676 for child in &node.children {
677 let child_path = current.join(&*child.name);
678 if let Some(size) = find_size(child, target, &child_path) {
679 return Some(size);
680 }
681 }
682 None
683 }
684
685 find_size(&tree.root, target, &tree.root_path)
686 }
687
688 fn move_up(&mut self) {
689 match self.view {
690 View::Explorer => self.tree_state.move_up(1),
691 View::Duplicates => {
692 self.selected_dup_group = self.selected_dup_group.saturating_sub(1);
693 }
694 View::Age => {
695 self.selected_stale_dir = self.selected_stale_dir.saturating_sub(1);
696 }
697 View::Errors => {
698 self.selected_warning = self.selected_warning.saturating_sub(1);
699 }
700 }
701 }
702
703 fn move_down(&mut self) {
704 match self.view {
705 View::Explorer => {
706 self.tree_state.move_down(1, self.cached_tree_len);
707 }
708 View::Duplicates => {
709 if let Some(dups) = &self.duplicates {
710 let max = dups.groups.len().saturating_sub(1);
711 self.selected_dup_group = (self.selected_dup_group + 1).min(max);
712 }
713 }
714 View::Age => {
715 if let Some(age) = &self.age_report {
716 let max = age.stale_directories.len().saturating_sub(1);
717 self.selected_stale_dir = (self.selected_stale_dir + 1).min(max);
718 }
719 }
720 View::Errors => {
721 let max = self.warnings.len().saturating_sub(1);
722 self.selected_warning = (self.selected_warning + 1).min(max);
723 }
724 }
725 }
726
727 fn page_up(&mut self) {
728 match self.view {
729 View::Explorer => self.tree_state.move_up(10),
730 View::Duplicates => {
731 self.selected_dup_group = self.selected_dup_group.saturating_sub(10);
732 }
733 View::Age => {
734 self.selected_stale_dir = self.selected_stale_dir.saturating_sub(10);
735 }
736 View::Errors => {
737 self.selected_warning = self.selected_warning.saturating_sub(10);
738 }
739 }
740 }
741
742 fn page_down(&mut self) {
743 match self.view {
744 View::Explorer => {
745 self.tree_state.move_down(10, self.cached_tree_len);
746 }
747 View::Duplicates => {
748 if let Some(dups) = &self.duplicates {
749 let max = dups.groups.len().saturating_sub(1);
750 self.selected_dup_group = (self.selected_dup_group + 10).min(max);
751 }
752 }
753 View::Age => {
754 if let Some(age) = &self.age_report {
755 let max = age.stale_directories.len().saturating_sub(1);
756 self.selected_stale_dir = (self.selected_stale_dir + 10).min(max);
757 }
758 }
759 View::Errors => {
760 let max = self.warnings.len().saturating_sub(1);
761 self.selected_warning = (self.selected_warning + 10).min(max);
762 }
763 }
764 }
765
766 fn jump_to_top(&mut self) {
767 match self.view {
768 View::Explorer => self.tree_state.jump_to_top(),
769 View::Duplicates => self.selected_dup_group = 0,
770 View::Age => self.selected_stale_dir = 0,
771 View::Errors => self.selected_warning = 0,
772 }
773 }
774
775 fn jump_to_bottom(&mut self) {
776 match self.view {
777 View::Explorer => {
778 self.tree_state.jump_to_bottom(self.cached_tree_len);
779 }
780 View::Duplicates => {
781 if let Some(dups) = &self.duplicates {
782 self.selected_dup_group = dups.groups.len().saturating_sub(1);
783 }
784 }
785 View::Age => {
786 if let Some(age) = &self.age_report {
787 self.selected_stale_dir = age.stale_directories.len().saturating_sub(1);
788 }
789 }
790 View::Errors => {
791 self.selected_warning = self.warnings.len().saturating_sub(1);
792 }
793 }
794 }
795
796 fn expand_selected(&mut self) {
797 if let Some((node, root_path)) = self.get_view_root_node() {
798 let items =
799 TreeView::new(node, &root_path, &self.theme).flatten(&self.tree_state);
800 if let Some(item) = items.get(self.tree_state.selected) {
801 self.tree_state.expand(&item.path);
802 self.update_cached_tree_len();
803 }
804 }
805 }
806
807 fn collapse_selected(&mut self) {
808 if let Some((node, root_path)) = self.get_view_root_node() {
809 let items =
810 TreeView::new(node, &root_path, &self.theme).flatten(&self.tree_state);
811 if let Some(item) = items.get(self.tree_state.selected) {
812 self.tree_state.collapse(&item.path);
813 self.update_cached_tree_len();
814 }
815 }
816 }
817
818 fn toggle_selected(&mut self) {
819 if let Some((node, root_path)) = self.get_view_root_node() {
820 let items =
821 TreeView::new(node, &root_path, &self.theme).flatten(&self.tree_state);
822 if let Some(item) = items.get(self.tree_state.selected) {
823 self.tree_state.toggle_expand(&item.path);
824 self.update_cached_tree_len();
825 }
826 }
827 }
828
829 fn get_selected_info(&self) -> Option<SelectedInfo> {
830 let tree = self.tree.as_ref()?;
831 let (view_node, view_path) = self.get_view_root_node()?;
832 let items =
833 TreeView::new(view_node, &view_path, &self.theme).flatten(&self.tree_state);
834 let item = items.get(self.tree_state.selected)?;
835
836 let node = Self::find_node_at_path(&tree.root, &item.path, &tree.root_path)?;
838
839 Some(SelectedInfo {
840 name: item.node.name.clone(),
841 path: item.path.clone(),
842 size: node.size,
843 file_count: node.file_count(),
844 dir_count: node.dir_count(),
845 modified: node.timestamps.modified,
846 is_dir: node.is_dir(),
847 })
848 }
849
850 fn drill_into_selected(&mut self) {
852 let Some((view_node, view_path)) = self.get_view_root_node() else { return };
853
854 let items = TreeView::new(view_node, &view_path, &self.theme)
856 .flatten(&self.tree_state);
857 let Some(item) = items.get(self.tree_state.selected) else { return };
858
859 if !matches!(item.node.kind, crate::ui::VisibleNodeKind::Directory { .. }) {
861 return;
862 }
863
864 if item.path == self.view_root {
866 self.tree_state.expand(&item.path);
868 self.update_cached_tree_len();
869 return;
870 }
871
872 let saved_expanded = self.tree_state.expanded.clone();
874 let saved_selected = self.tree_state.selected;
875 self.view_history.push((self.view_root.clone(), saved_selected, saved_expanded));
876
877 self.view_root = item.path.clone();
879
880 self.tree_state.selected = 0;
882 self.tree_state.offset = 0;
883 self.tree_state.expand(&self.view_root);
884 self.update_cached_tree_len();
885 }
886
887 fn navigate_back(&mut self) {
889 if let Some((prev_root, saved_selected, saved_expanded)) = self.view_history.pop() {
891 self.view_root = prev_root;
893
894 self.tree_state.expanded = saved_expanded;
896
897 self.tree_state.selected = saved_selected;
899 self.tree_state.offset = 0;
900 self.update_cached_tree_len();
901 } else if self.view_root != self.path {
902 if let Some(parent) = self.view_root.parent() {
904 if parent.starts_with(&self.path) || parent == self.path {
906 let dir_we_drilled_into = self.view_root.clone();
908 self.tree_state.collapse(&dir_we_drilled_into);
909
910 self.view_root = parent.to_path_buf();
911 self.tree_state.expand(&self.view_root);
912 self.tree_state.selected = 0;
913 self.tree_state.offset = 0;
914 self.update_cached_tree_len();
915 }
916 }
917 }
918 }
919
920 fn find_node_at_path<'a>(
922 node: &'a gravityfile_core::FileNode,
923 target_path: &PathBuf,
924 current_path: &PathBuf,
925 ) -> Option<&'a gravityfile_core::FileNode> {
926 if current_path == target_path {
927 return Some(node);
928 }
929 for child in &node.children {
930 let child_path = current_path.join(&*child.name);
931 if let Some(found) = Self::find_node_at_path(child, target_path, &child_path) {
932 return Some(found);
933 }
934 }
935 None
936 }
937
938 fn get_view_root_node(&self) -> Option<(&gravityfile_core::FileNode, PathBuf)> {
940 let tree = self.tree.as_ref()?;
941 let node = Self::find_node_at_path(&tree.root, &self.view_root, &tree.root_path)?;
942 Some((node, self.view_root.clone()))
943 }
944
945 fn handle_command_input(&mut self, key: KeyEvent) {
947 match (key.code, key.modifiers) {
948 (KeyCode::Enter, _) => {
950 let cmd = self.command_input.clone();
951 self.command_input.clear();
952 self.command_cursor = 0;
953 self.mode = AppMode::Normal;
954 self.execute_command(&cmd);
955 }
956 (KeyCode::Esc, _) => {
958 self.command_input.clear();
959 self.command_cursor = 0;
960 self.mode = AppMode::Normal;
961 }
962 (KeyCode::Backspace, _) => {
964 if self.command_cursor > 0 {
965 self.command_cursor -= 1;
966 self.command_input.remove(self.command_cursor);
967 } else if self.command_input.is_empty() {
968 self.mode = AppMode::Normal;
970 }
971 }
972 (KeyCode::Delete, _) => {
974 if self.command_cursor < self.command_input.len() {
975 self.command_input.remove(self.command_cursor);
976 }
977 }
978 (KeyCode::Left, _) => {
980 self.command_cursor = self.command_cursor.saturating_sub(1);
981 }
982 (KeyCode::Right, _) => {
983 self.command_cursor = (self.command_cursor + 1).min(self.command_input.len());
984 }
985 (KeyCode::Home, _) | (KeyCode::Char('a'), KeyModifiers::CONTROL) => {
986 self.command_cursor = 0;
987 }
988 (KeyCode::End, _) | (KeyCode::Char('e'), KeyModifiers::CONTROL) => {
989 self.command_cursor = self.command_input.len();
990 }
991 (KeyCode::Char('u'), KeyModifiers::CONTROL) => {
993 self.command_input.clear();
994 self.command_cursor = 0;
995 }
996 (KeyCode::Char(c), KeyModifiers::NONE | KeyModifiers::SHIFT) => {
998 self.command_input.insert(self.command_cursor, c);
999 self.command_cursor += 1;
1000 }
1001 _ => {}
1002 }
1003 }
1004
1005 fn execute_command(&mut self, cmd: &str) {
1007 let parts: Vec<&str> = cmd.trim().split_whitespace().collect();
1008 if parts.is_empty() {
1009 return;
1010 }
1011
1012 match parts[0] {
1013 "q" | "quit" | "exit" => {
1015 self.mode = AppMode::Quit;
1016 }
1017 "r" | "refresh" | "rescan" => {
1019 self.marked_for_deletion.clear();
1020 self.start_scan();
1021 }
1022 "cd" => {
1024 if parts.len() > 1 {
1025 let target = parts[1..].join(" ");
1026 self.cd_to_path(&target);
1027 } else {
1028 self.go_to_root();
1030 }
1031 }
1032 "root" | "top" => {
1034 self.go_to_root();
1035 }
1036 "back" | "up" | ".." => {
1038 self.navigate_back();
1039 }
1040 "help" | "?" => {
1042 self.mode = AppMode::Help;
1043 }
1044 "explorer" | "e" | "tree" => {
1046 self.view = View::Explorer;
1047 }
1048 "duplicates" | "dups" | "d" => {
1049 self.view = View::Duplicates;
1050 }
1051 "age" | "a" => {
1052 self.view = View::Age;
1053 }
1054 "errors" | "err" => {
1055 self.view = View::Errors;
1056 }
1057 "clear" | "unmark" => {
1059 self.marked_for_deletion.clear();
1060 }
1061 "details" | "info" | "i" => {
1063 self.show_details = !self.show_details;
1064 }
1065 "theme" | "t" => {
1067 if parts.len() > 1 {
1068 match parts[1] {
1069 "dark" => self.theme = Theme::dark(),
1070 "light" => self.theme = Theme::light(),
1071 "toggle" => self.theme = self.theme.toggle(),
1072 _ => {}
1073 }
1074 } else {
1075 self.theme = self.theme.toggle();
1077 }
1078 }
1079 "dark" => {
1080 self.theme = Theme::dark();
1081 }
1082 "light" => {
1083 self.theme = Theme::light();
1084 }
1085 _ => {
1086 }
1088 }
1089 }
1090
1091 fn go_to_root(&mut self) {
1093 self.view_root = self.path.clone();
1094 self.view_history.clear();
1095 self.tree_state.selected = 0;
1096 self.tree_state.offset = 0;
1097 self.tree_state.expand(&self.view_root);
1098 self.update_cached_tree_len();
1099 }
1100
1101 fn cd_to_path(&mut self, target: &str) {
1103 if target == "/" || target == "~" {
1105 self.go_to_root();
1106 return;
1107 }
1108 if target == ".." {
1109 self.navigate_back();
1110 return;
1111 }
1112
1113 let Some(tree) = &self.tree else { return };
1115
1116 let target_path = if target.starts_with('/') {
1117 let relative = target.trim_start_matches('/');
1119 self.path.join(relative)
1120 } else {
1121 self.view_root.join(target)
1123 };
1124
1125 if let Some(node) = Self::find_node_at_path(&tree.root, &target_path, &tree.root_path) {
1127 if node.is_dir() {
1128 let saved_expanded = self.tree_state.expanded.clone();
1130 let saved_selected = self.tree_state.selected;
1131 self.view_history.push((self.view_root.clone(), saved_selected, saved_expanded));
1132
1133 self.view_root = target_path;
1134 self.tree_state.selected = 0;
1135 self.tree_state.offset = 0;
1136 self.tree_state.expand(&self.view_root);
1137 self.update_cached_tree_len();
1138 }
1139 }
1140 }
1141}
1142
1143struct SelectedInfo {
1144 name: String,
1145 path: PathBuf,
1146 size: u64,
1147 file_count: u64,
1148 dir_count: u64,
1149 modified: std::time::SystemTime,
1150 is_dir: bool,
1151}
1152
1153impl Widget for &App {
1154 fn render(self, area: Rect, buf: &mut Buffer) {
1155 let base_style = Style::default().bg(self.theme.background).fg(self.theme.foreground);
1157 buf.set_style(area, base_style);
1158
1159 let [header, tabs_area, content, footer] = Layout::vertical([
1161 Constraint::Length(1),
1162 Constraint::Length(1),
1163 Constraint::Min(10),
1164 Constraint::Length(1),
1165 ])
1166 .areas(area);
1167
1168 self.render_header(header, buf);
1170
1171 self.render_tabs(tabs_area, buf);
1173
1174 let is_scanning = self.scan_progress.is_some() && self.tree.is_none();
1177
1178 if is_scanning {
1179 match self.scan_view {
1180 ScanView::Progress => self.render_scanning(content, buf),
1181 ScanView::Errors => self.render_errors(content, buf),
1182 }
1183 } else {
1184 match self.view {
1185 View::Explorer => self.render_explorer(content, buf),
1186 View::Duplicates => self.render_duplicates(content, buf),
1187 View::Age => self.render_age(content, buf),
1188 View::Errors => self.render_errors(content, buf),
1189 }
1190 }
1191
1192 self.render_footer(footer, buf);
1194
1195 match self.mode {
1197 AppMode::Help => {
1198 HelpOverlay::new(&self.theme).render(area, buf);
1199 }
1200 AppMode::ConfirmDelete => {
1201 self.render_delete_confirmation(area, buf);
1202 }
1203 AppMode::Command => {
1204 self.render_command_palette(footer, buf);
1205 }
1206 _ => {}
1207 }
1208 }
1209}
1210
1211impl App {
1212 fn render_header(&self, area: Rect, buf: &mut Buffer) {
1213 let title = Span::styled(
1214 " gravityfile ",
1215 self.theme.title.add_modifier(Modifier::BOLD),
1216 );
1217
1218 let stats = if let Some(tree) = &self.tree {
1219 format!(
1220 " {} in {} files, {} dirs ",
1221 format_size(tree.stats.total_size),
1222 tree.stats.total_files,
1223 tree.stats.total_dirs
1224 )
1225 } else {
1226 String::new()
1227 };
1228
1229 let stats_span = Span::styled(stats, self.theme.header);
1230
1231 let status = if let Some((success, msg)) = &self.deletion_message {
1233 let color = if *success {
1234 self.theme.success
1235 } else {
1236 self.theme.warning
1237 };
1238 Span::styled(format!(" {} ", msg), Style::default().fg(color))
1239 } else if !self.marked_for_deletion.is_empty() {
1240 let total_size: u64 = self
1241 .marked_for_deletion
1242 .iter()
1243 .filter_map(|p| self.get_path_size(p))
1244 .sum();
1245 Span::styled(
1246 format!(
1247 " {} marked ({}) [Enter to delete] ",
1248 self.marked_for_deletion.len(),
1249 format_size(total_size)
1250 ),
1251 Style::default().fg(self.theme.warning),
1252 )
1253 } else {
1254 Span::raw("")
1255 };
1256
1257 let line = Line::from(vec![title, Span::raw(" "), stats_span, status]);
1258
1259 Paragraph::new(line)
1260 .style(self.theme.header)
1261 .render(area, buf);
1262 }
1263
1264 fn render_delete_confirmation(&self, area: Rect, buf: &mut Buffer) {
1265 let popup_width = 60.min(area.width.saturating_sub(4));
1267 let popup_height = (self.marked_for_deletion.len() as u16 + 8).min(area.height.saturating_sub(4));
1268
1269 let popup_x = (area.width.saturating_sub(popup_width)) / 2 + area.x;
1270 let popup_y = (area.height.saturating_sub(popup_height)) / 2 + area.y;
1271
1272 let popup_area = Rect::new(popup_x, popup_y, popup_width, popup_height);
1273
1274 Clear.render(popup_area, buf);
1276
1277 let block = Block::default()
1279 .title(" Confirm Deletion ")
1280 .title_style(Style::default().fg(self.theme.error).add_modifier(Modifier::BOLD))
1281 .borders(Borders::ALL)
1282 .border_style(Style::default().fg(self.theme.error));
1283
1284 let inner = block.inner(popup_area);
1285 block.render(popup_area, buf);
1286
1287 let total_size: u64 = self
1289 .marked_for_deletion
1290 .iter()
1291 .filter_map(|p| self.get_path_size(p))
1292 .sum();
1293
1294 let mut lines = vec![
1296 Line::styled(
1297 format!(
1298 "Delete {} items ({})? This cannot be undone!",
1299 self.marked_for_deletion.len(),
1300 format_size(total_size)
1301 ),
1302 Style::default().fg(self.theme.warning).add_modifier(Modifier::BOLD),
1303 ),
1304 Line::raw(""),
1305 ];
1306
1307 let max_items = (inner.height as usize).saturating_sub(5);
1309 for path in self.marked_for_deletion.iter().take(max_items) {
1310 let name = path
1311 .file_name()
1312 .map(|n| n.to_string_lossy().to_string())
1313 .unwrap_or_else(|| path.display().to_string());
1314 let prefix = if path.is_dir() { "📁 " } else { "📄 " };
1315 lines.push(Line::raw(format!(" {}{}", prefix, name)));
1316 }
1317
1318 if self.marked_for_deletion.len() > max_items {
1319 lines.push(Line::styled(
1320 format!(" ... and {} more", self.marked_for_deletion.len() - max_items),
1321 Style::default().fg(self.theme.muted),
1322 ));
1323 }
1324
1325 lines.push(Line::raw(""));
1326 lines.push(Line::from(vec![
1327 Span::styled(" y/Enter ", self.theme.help_key),
1328 Span::raw("Confirm "),
1329 Span::styled(" n/Esc ", self.theme.help_key),
1330 Span::raw("Cancel"),
1331 ]));
1332
1333 Paragraph::new(lines).render(inner, buf);
1334 }
1335
1336 fn render_tabs(&self, area: Rect, buf: &mut Buffer) {
1337 let is_scanning = self.scan_progress.is_some() && self.tree.is_none();
1339
1340 if is_scanning {
1341 let error_count = self.scan_progress
1344 .as_ref()
1345 .map(|p| p.errors_count)
1346 .unwrap_or(0);
1347 let titles = vec![
1348 " Progress ".to_string(),
1349 if error_count > 0 {
1350 format!(" Errors ({}) ", error_count)
1351 } else {
1352 " Errors ".to_string()
1353 },
1354 ];
1355
1356 let tabs = Tabs::new(titles)
1357 .select(self.scan_view as usize)
1358 .style(self.theme.footer)
1359 .highlight_style(self.theme.selected);
1360
1361 tabs.render(area, buf);
1362 } else {
1363 let titles: Vec<String> = View::iter().map(|v| format!(" {} ", v)).collect();
1365
1366 let tabs = Tabs::new(titles)
1367 .select(self.view as usize)
1368 .style(self.theme.footer)
1369 .highlight_style(self.theme.selected);
1370
1371 tabs.render(area, buf);
1372 }
1373 }
1374
1375 fn render_scanning(&self, area: Rect, buf: &mut Buffer) {
1376 let block = Block::default()
1377 .borders(Borders::ALL)
1378 .border_style(self.theme.border)
1379 .title(format!(" Scanning {} ", self.path.display()))
1380 .title_style(self.theme.title);
1381
1382 let inner = block.inner(area);
1383 block.render(area, buf);
1384
1385 let mut lines = vec![
1386 Line::raw(""),
1387 Line::styled(
1388 " Scanning directory...",
1389 Style::default().fg(self.theme.info).add_modifier(Modifier::BOLD),
1390 ),
1391 Line::raw(""),
1392 ];
1393
1394 if let Some(progress) = &self.scan_progress {
1395 lines.push(Line::from(vec![
1396 Span::styled(" Files: ", self.theme.help_desc),
1397 Span::raw(progress.files_scanned.to_string()),
1398 ]));
1399 lines.push(Line::from(vec![
1400 Span::styled(" Dirs: ", self.theme.help_desc),
1401 Span::raw(progress.dirs_scanned.to_string()),
1402 ]));
1403 lines.push(Line::from(vec![
1404 Span::styled(" Size: ", self.theme.help_desc),
1405 Span::raw(format_size(progress.bytes_scanned)),
1406 ]));
1407
1408 if progress.errors_count > 0 {
1409 lines.push(Line::from(vec![
1410 Span::styled(" Errors: ", Style::default().fg(self.theme.warning)),
1411 Span::styled(
1412 progress.errors_count.to_string(),
1413 Style::default().fg(self.theme.warning),
1414 ),
1415 ]));
1416 }
1417
1418 let current = progress.current_path.display().to_string();
1420 let max_width = inner.width.saturating_sub(4) as usize;
1421 let display_path = if current.len() > max_width {
1422 format!("...{}", ¤t[current.len().saturating_sub(max_width - 3)..])
1423 } else {
1424 current
1425 };
1426 lines.push(Line::raw(""));
1427 lines.push(Line::styled(
1428 format!(" {}", display_path),
1429 Style::default().fg(self.theme.muted),
1430 ));
1431 }
1432
1433 Paragraph::new(lines).render(inner, buf);
1434 }
1435
1436 fn render_explorer(&self, area: Rect, buf: &mut Buffer) {
1437 let layout = AppLayout::new(area, self.show_details);
1438
1439 if let Some((view_node, view_path)) = self.get_view_root_node() {
1440 let title = if self.view_root != self.path {
1442 let relative = self.view_root.strip_prefix(&self.path)
1444 .map(|p| p.display().to_string())
1445 .unwrap_or_else(|_| self.view_root.display().to_string());
1446 format!(" {} (← Backspace) ", relative)
1447 } else {
1448 format!(" {} ", view_path.display())
1449 };
1450
1451 let tree_view = TreeView::new(view_node, &view_path, &self.theme).block(
1452 Block::default()
1453 .borders(Borders::ALL)
1454 .border_style(self.theme.border)
1455 .title(title)
1456 .title_style(self.theme.title),
1457 );
1458
1459 let mut tree_state = self.tree_state.clone();
1460 ratatui::widgets::StatefulWidget::render(tree_view, layout.main, buf, &mut tree_state);
1461 } else if let Some(error) = &self.error {
1462 let error_block = Block::default()
1463 .borders(Borders::ALL)
1464 .border_style(self.theme.border)
1465 .title(" Error ")
1466 .title_style(Style::default().fg(self.theme.error));
1467
1468 let error_text = Paragraph::new(error.as_str())
1469 .block(error_block)
1470 .style(Style::default().fg(self.theme.error));
1471
1472 error_text.render(layout.main, buf);
1473 }
1474
1475 if let Some(details_area) = layout.details {
1476 self.render_details(details_area, buf);
1477 }
1478 }
1479
1480 fn render_duplicates(&self, area: Rect, buf: &mut Buffer) {
1481 let block = Block::default()
1482 .borders(Borders::ALL)
1483 .border_style(self.theme.border)
1484 .title(" Duplicates ")
1485 .title_style(self.theme.title);
1486
1487 let inner = block.inner(area);
1488 block.render(area, buf);
1489
1490 if let Some(dups) = &self.duplicates {
1491 if dups.groups.is_empty() {
1492 let msg = Paragraph::new("No duplicate files found.")
1493 .style(Style::default().fg(self.theme.muted));
1494 msg.render(inner, buf);
1495 return;
1496 }
1497
1498 let header = format!(
1500 " {} groups, {} wasted",
1501 dups.group_count,
1502 format_size(dups.total_wasted_space)
1503 );
1504 let header_line = Line::styled(header, self.theme.title);
1505 let header_area = Rect::new(inner.x, inner.y, inner.width, 1);
1506 Paragraph::new(header_line).render(header_area, buf);
1507
1508 let list_area = Rect::new(inner.x, inner.y + 2, inner.width, inner.height.saturating_sub(2));
1510 let visible_height = list_area.height as usize;
1511
1512 let scroll_offset = if self.selected_dup_group >= visible_height {
1514 self.selected_dup_group - visible_height + 1
1515 } else {
1516 0
1517 };
1518
1519 for (i, group) in dups.groups.iter().enumerate().skip(scroll_offset).take(visible_height) {
1520 let y = list_area.y + (i - scroll_offset) as u16;
1521 let is_selected = i == self.selected_dup_group;
1522
1523 let line = format!(
1524 " {} files, {} each ({} wasted)",
1525 group.count(),
1526 format_size(group.size),
1527 format_size(group.wasted_bytes)
1528 );
1529
1530 let style = if is_selected {
1531 self.theme.selected
1532 } else {
1533 Style::default()
1534 };
1535
1536 let line = Line::styled(line, style);
1537 let line_area = Rect::new(list_area.x, y, list_area.width, 1);
1538 Paragraph::new(line).render(line_area, buf);
1539 }
1540 } else {
1541 let msg = Paragraph::new("Analyzing duplicates...")
1542 .style(Style::default().fg(self.theme.muted));
1543 msg.render(inner, buf);
1544 }
1545 }
1546
1547 fn render_age(&self, area: Rect, buf: &mut Buffer) {
1548 let block = Block::default()
1549 .borders(Borders::ALL)
1550 .border_style(self.theme.border)
1551 .title(" Age Analysis ")
1552 .title_style(self.theme.title);
1553
1554 let inner = block.inner(area);
1555 block.render(area, buf);
1556
1557 if let Some(age) = &self.age_report {
1558 let max_size = age.buckets.iter().map(|b| b.total_size).max().unwrap_or(1);
1560 let chart_height = age.buckets.len().min(inner.height as usize / 2);
1561
1562 for (i, bucket) in age.buckets.iter().enumerate().take(chart_height) {
1563 let y = inner.y + i as u16;
1564 let bar_width = if max_size > 0 {
1565 ((bucket.total_size as f64 / max_size as f64) * 20.0) as usize
1566 } else {
1567 0
1568 };
1569
1570 let bar = "█".repeat(bar_width);
1571 let line = format!(
1572 " {:<12} {:>10} {:>8} files {}",
1573 bucket.name,
1574 format_size(bucket.total_size),
1575 bucket.file_count,
1576 bar
1577 );
1578
1579 let line_area = Rect::new(inner.x, y, inner.width, 1);
1580 Paragraph::new(line).render(line_area, buf);
1581 }
1582
1583 let stale_y = inner.y + chart_height as u16 + 1;
1585 if stale_y < inner.y + inner.height {
1586 let stale_header = if age.stale_directories.is_empty() {
1587 " No stale directories found.".to_string()
1588 } else {
1589 format!(
1590 " Stale Directories ({}, {} total)",
1591 age.stale_directories.len(),
1592 format_size(age.total_stale_size())
1593 )
1594 };
1595 let header_area = Rect::new(inner.x, stale_y, inner.width, 1);
1596 Paragraph::new(Line::styled(stale_header, self.theme.title)).render(header_area, buf);
1597
1598 let list_y = stale_y + 1;
1600 let list_height = (inner.y + inner.height).saturating_sub(list_y) as usize;
1601
1602 for (i, dir) in age.stale_directories.iter().enumerate().take(list_height) {
1603 let y = list_y + i as u16;
1604 let is_selected = i == self.selected_stale_dir;
1605
1606 let line = format!(
1607 " {} ({}, {} old)",
1608 dir.path.file_name().map(|n| n.to_string_lossy()).unwrap_or_default(),
1609 format_size(dir.size),
1610 gravityfile_analyze::format_age(dir.newest_file_age)
1611 );
1612
1613 let style = if is_selected {
1614 self.theme.selected
1615 } else {
1616 Style::default()
1617 };
1618
1619 let line_area = Rect::new(inner.x, y, inner.width, 1);
1620 Paragraph::new(Line::styled(line, style)).render(line_area, buf);
1621 }
1622 }
1623 } else {
1624 let msg = Paragraph::new("Analyzing file ages...")
1625 .style(Style::default().fg(self.theme.muted));
1626 msg.render(inner, buf);
1627 }
1628 }
1629
1630 fn render_errors(&self, area: Rect, buf: &mut Buffer) {
1631 let title = if self.mode == AppMode::Scanning {
1632 let count = self.scan_progress
1634 .as_ref()
1635 .map(|p| p.errors_count)
1636 .unwrap_or(0);
1637 format!(" Errors & Warnings ({}) ", count)
1638 } else if !self.warnings.is_empty() {
1639 format!(" Scan Errors & Warnings ({}) ", self.warnings.len())
1640 } else {
1641 " Scan Errors & Warnings ".to_string()
1642 };
1643
1644 let block = Block::default()
1645 .borders(Borders::ALL)
1646 .border_style(self.theme.border)
1647 .title(title)
1648 .title_style(self.theme.title);
1649
1650 let inner = block.inner(area);
1651 block.render(area, buf);
1652
1653 if self.warnings.is_empty() {
1654 let lines = if self.mode == AppMode::Scanning {
1656 let error_count = self.scan_progress
1657 .as_ref()
1658 .map(|p| p.errors_count)
1659 .unwrap_or(0);
1660 if error_count > 0 {
1661 vec![
1662 Line::raw(""),
1663 Line::styled(
1664 format!(" {} errors encountered during scan", error_count),
1665 Style::default().fg(self.theme.warning),
1666 ),
1667 Line::raw(""),
1668 Line::styled(
1669 " Error details will be available after scan completes.",
1670 Style::default().fg(self.theme.muted),
1671 ),
1672 ]
1673 } else {
1674 vec![Line::styled(
1675 "No errors yet...",
1676 Style::default().fg(self.theme.muted),
1677 )]
1678 }
1679 } else {
1680 vec![Line::styled(
1681 "No errors or warnings during scan.",
1682 Style::default().fg(self.theme.muted),
1683 )]
1684 };
1685 Paragraph::new(lines).render(inner, buf);
1686 return;
1687 }
1688
1689 let lines_per_item = 2;
1691 let visible_items = inner.height as usize / lines_per_item;
1692
1693 let scroll_offset = if self.selected_warning >= visible_items {
1695 self.selected_warning - visible_items + 1
1696 } else {
1697 0
1698 };
1699
1700 for (i, warning) in self.warnings.iter().enumerate().skip(scroll_offset).take(visible_items) {
1701 let base_y = inner.y + ((i - scroll_offset) * lines_per_item) as u16;
1702 let is_selected = i == self.selected_warning;
1703
1704 let (icon, kind_label) = match warning.kind {
1706 gravityfile_core::WarningKind::PermissionDenied => ("🔒", "Permission Denied"),
1707 gravityfile_core::WarningKind::BrokenSymlink => ("🔗", "Broken Symlink"),
1708 gravityfile_core::WarningKind::ReadError => ("⚠", "Read Error"),
1709 gravityfile_core::WarningKind::MetadataError => ("📋", "Metadata Error"),
1710 gravityfile_core::WarningKind::CrossFilesystem => ("💾", "Cross Filesystem"),
1711 };
1712
1713 let path_str = warning.path.display().to_string();
1715 let prefix = format!(" {} {} ", icon, kind_label);
1716 let available_width = (inner.width as usize).saturating_sub(prefix.len() + 1);
1717 let display_path = if path_str.len() > available_width {
1718 format!("...{}", &path_str[path_str.len().saturating_sub(available_width - 3)..])
1719 } else {
1720 path_str
1721 };
1722
1723 let style = if is_selected {
1724 self.theme.selected
1725 } else {
1726 Style::default().fg(self.theme.warning)
1727 };
1728
1729 let line1 = Line::from(vec![
1730 Span::styled(prefix, style),
1731 Span::styled(display_path, style),
1732 ]);
1733 let line1_area = Rect::new(inner.x, base_y, inner.width, 1);
1734 Paragraph::new(line1).render(line1_area, buf);
1735
1736 if base_y + 1 < inner.y + inner.height {
1738 let msg_style = if is_selected {
1739 self.theme.selected
1740 } else {
1741 Style::default().fg(self.theme.muted)
1742 };
1743
1744 let max_msg_len = (inner.width as usize).saturating_sub(4);
1746 let msg = if warning.message.len() > max_msg_len {
1747 format!("{}...", &warning.message[..max_msg_len.saturating_sub(3)])
1748 } else {
1749 warning.message.clone()
1750 };
1751
1752 let line2 = Line::styled(format!(" {}", msg), msg_style);
1753 let line2_area = Rect::new(inner.x, base_y + 1, inner.width, 1);
1754 Paragraph::new(line2).render(line2_area, buf);
1755 }
1756 }
1757 }
1758
1759 fn render_details(&self, area: Rect, buf: &mut Buffer) {
1760 let block = Block::default()
1761 .borders(Borders::ALL)
1762 .border_style(self.theme.border)
1763 .title(" Details ")
1764 .title_style(self.theme.title);
1765
1766 let inner = block.inner(area);
1767 block.render(area, buf);
1768
1769 if let Some(info) = self.get_selected_info() {
1770 let mut lines = vec![
1771 Line::from(Span::styled(
1772 &info.name,
1773 self.theme.title.add_modifier(Modifier::BOLD),
1774 )),
1775 Line::raw(""),
1776 Line::from(vec![
1777 Span::styled("Size: ", self.theme.help_desc),
1778 Span::raw(format_size(info.size)),
1779 ]),
1780 ];
1781
1782 if info.is_dir {
1783 lines.push(Line::from(vec![
1784 Span::styled("Files: ", self.theme.help_desc),
1785 Span::raw(info.file_count.to_string()),
1786 ]));
1787 lines.push(Line::from(vec![
1788 Span::styled("Dirs: ", self.theme.help_desc),
1789 Span::raw(info.dir_count.to_string()),
1790 ]));
1791 }
1792
1793 lines.push(Line::from(vec![
1794 Span::styled("Modified: ", self.theme.help_desc),
1795 Span::raw(format_relative_time(info.modified)),
1796 ]));
1797
1798 lines.push(Line::raw(""));
1799 lines.push(Line::styled("Path:", self.theme.help_desc));
1800
1801 let path_str = info.path.display().to_string();
1802 let max_width = inner.width.saturating_sub(2) as usize;
1803 for chunk in path_str
1804 .chars()
1805 .collect::<Vec<_>>()
1806 .chunks(max_width)
1807 .map(|c| c.iter().collect::<String>())
1808 {
1809 lines.push(Line::raw(chunk));
1810 }
1811
1812 Paragraph::new(lines).render(inner, buf);
1813 }
1814 }
1815
1816 fn render_footer(&self, area: Rect, buf: &mut Buffer) {
1817 let mut keys: Vec<(&str, &str)> = match self.view {
1818 View::Explorer => {
1819 let mut v = vec![
1820 ("j/k", "Nav"),
1821 ("h/l", "Fold"),
1822 ("Enter", "Drill"),
1823 ];
1824 if self.view_root != self.path {
1826 v.push(("⌫", "Back"));
1827 }
1828 v.push(("d", "Mark"));
1829 v
1830 }
1831 View::Duplicates | View::Age => vec![
1832 ("j/k", "Nav"),
1833 ("d", "Mark"),
1834 ],
1835 View::Errors => vec![
1836 ("j/k", "Nav"),
1837 ],
1838 };
1839
1840 if !self.marked_for_deletion.is_empty() {
1842 keys.push(("x", "Clear"));
1843 keys.push(("y", "Delete"));
1844 }
1845
1846 keys.extend([(":", "Cmd"), ("Tab", "View"), ("?", "Help"), ("q", "Quit")]);
1847
1848 let spans: Vec<Span> = keys
1849 .iter()
1850 .flat_map(|(key, desc)| {
1851 vec![
1852 Span::styled(format!(" {key} "), self.theme.help_key),
1853 Span::styled(format!("{desc} "), self.theme.help_desc),
1854 ]
1855 })
1856 .collect();
1857
1858 let line = Line::from(spans);
1859
1860 Paragraph::new(line)
1861 .style(self.theme.footer)
1862 .render(area, buf);
1863 }
1864
1865 fn render_command_palette(&self, area: Rect, buf: &mut Buffer) {
1867 Clear.render(area, buf);
1869
1870 let prompt = ":";
1872 let input = &self.command_input;
1873
1874 let cursor_pos = self.command_cursor;
1876
1877 let mut spans = vec![
1879 Span::styled(prompt, Style::default().fg(self.theme.info).add_modifier(Modifier::BOLD)),
1880 ];
1881
1882 if input.is_empty() {
1883 spans.push(Span::styled(
1885 "type command (q, cd, help...)",
1886 Style::default().fg(self.theme.muted),
1887 ));
1888 } else {
1889 let (before, after) = input.split_at(cursor_pos.min(input.len()));
1891 spans.push(Span::raw(before));
1892
1893 if !after.is_empty() {
1894 let (cursor_char, rest) = after.split_at(1);
1896 spans.push(Span::styled(
1897 cursor_char,
1898 Style::default().add_modifier(Modifier::REVERSED),
1899 ));
1900 spans.push(Span::raw(rest));
1901 } else {
1902 spans.push(Span::styled(" ", Style::default().add_modifier(Modifier::REVERSED)));
1904 }
1905 }
1906
1907 let line = Line::from(spans);
1908 Paragraph::new(line)
1909 .style(self.theme.footer)
1910 .render(area, buf);
1911 }
1912
1913 pub fn is_marked(&self, path: &PathBuf) -> bool {
1915 self.marked_for_deletion.contains(path)
1916 }
1917}