1mod commands;
4mod constants;
5mod deletion;
6pub mod input;
7mod navigation;
8mod render;
9pub mod state;
10mod scanning;
11
12use std::collections::{HashMap, HashSet};
13use std::path::PathBuf;
14use std::time::Duration;
15
16use crossterm::event::{Event, EventStream};
17use futures::StreamExt;
18use ratatui::buffer::Buffer;
19use ratatui::layout::Rect;
20use ratatui::widgets::Widget;
21use ratatui::{DefaultTerminal, Frame};
22use tokio::sync::mpsc;
23
24use gravityfile_analyze::{AgeReport, DuplicateReport};
25use gravityfile_core::FileTree;
26use gravityfile_ops::{
27 Conflict, CopyOptions, CopyResult, MoveOptions, MoveResult, OperationProgress, UndoLog,
28};
29use gravityfile_scan::ScanProgress;
30
31use crate::event::KeyAction;
32use crate::preview::PreviewState;
33use crate::search::SearchState;
34use crate::theme::Theme;
35use crate::ui::{TreeState, TreeView};
36use crate::TuiConfig;
37
38use self::commands::{parse_command, CommandAction, CommandInput, CommandKeyResult, LayoutCommand, SortCommand, ThemeCommand};
39use self::constants::{PAGE_SIZE, TICK_INTERVAL_MS};
40use self::input::{InputResult, InputState};
41use self::render::{render_app, RenderContext};
42use self::state::{
43 AppMode, ClipboardMode, ClipboardState, DeletionProgress, DuplicatesViewState, LayoutMode,
44 PendingOperation, ScanResult, SelectedInfo, SettingsState, SortMode, TabManager, UserSettings, View,
45};
46
47pub type AppResult<T> = color_eyre::Result<T>;
49
50struct LayoutSyncData {
52 view_path: PathBuf,
54 miller_index: Option<usize>,
56 tree_index: Option<usize>,
58 new_view_root: Option<PathBuf>,
60}
61
62pub struct App {
64 path: PathBuf,
66 view_root: PathBuf,
68 view_history: Vec<(PathBuf, usize, HashSet<PathBuf>)>,
70 forward_history: Vec<(PathBuf, usize, HashSet<PathBuf>)>,
72 mode: AppMode,
74 view: View,
76 theme: Theme,
78 tree: Option<FileTree>,
80 tree_state: TreeState,
82 cached_tree_len: usize,
84 duplicates: Option<DuplicateReport>,
86 age_report: Option<AgeReport>,
88 duplicates_state: DuplicatesViewState,
90 selected_stale_dir: usize,
92 show_details: bool,
94 error: Option<String>,
96 marked: HashSet<PathBuf>,
98 clipboard: ClipboardState,
100 #[allow(dead_code)]
102 undo_log: UndoLog,
103 layout_mode: LayoutMode,
105 miller_state: crate::ui::MillerState,
107 cached_miller_len: usize,
109 input_state: Option<InputState>,
111 operation_progress: Option<OperationProgress>,
113 pending_conflict: Option<Conflict>,
115 pending_operation: Option<PendingOperation>,
117 operation_message: Option<(bool, String)>,
119 deletion_message: Option<(bool, String)>,
121 deletion_progress: Option<DeletionProgress>,
123 scan_progress: Option<ScanProgress>,
125 scan_rx: Option<mpsc::Receiver<ScanResult>>,
127 warnings: Vec<gravityfile_core::ScanWarning>,
129 selected_warning: usize,
131 analyzing: bool,
133 has_full_scan: bool,
135 needs_redraw: bool,
137 command_input: CommandInput,
139 sort_mode: SortMode,
141 search_state: SearchState,
143 tab_manager: TabManager,
145 preview_state: PreviewState,
147 scan_on_startup: bool,
149 user_settings: UserSettings,
151 settings_state: Option<SettingsState>,
153 cached_parent_tree: Option<FileTree>,
156 pending_suspend_command: Option<std::process::Command>,
158 scanned_cache: HashMap<PathBuf, FileTree>,
162}
163
164impl App {
165 pub fn new(path: PathBuf) -> Self {
168 Self::with_config(path, TuiConfig::default())
169 }
170
171 pub fn with_config(path: PathBuf, config: TuiConfig) -> Self {
175 let user_settings = UserSettings::load();
177
178 let quick_tree = gravityfile_scan::quick_list(&path).ok();
180
181 let scan_on_startup = if config.scan_on_startup {
184 true } else {
186 user_settings.scan_on_startup
187 };
188
189 let mut app = Self {
190 path: path.clone(),
191 view_root: path.clone(),
192 view_history: Vec::new(),
193 forward_history: Vec::new(),
194 mode: AppMode::default(),
195 view: View::default(),
196 theme: Theme::dark(),
197 tree: quick_tree,
198 tree_state: TreeState::new(path.clone()),
199 cached_tree_len: 0,
200 duplicates: None,
201 age_report: None,
202 duplicates_state: DuplicatesViewState::new(),
203 selected_stale_dir: 0,
204 show_details: true,
205 error: None,
206 marked: HashSet::new(),
207 clipboard: ClipboardState::default(),
208 undo_log: UndoLog::new(100),
209 layout_mode: LayoutMode::default(),
210 miller_state: crate::ui::MillerState::new(),
211 cached_miller_len: 0,
212 input_state: None,
213 operation_progress: None,
214 pending_conflict: None,
215 pending_operation: None,
216 operation_message: None,
217 deletion_message: None,
218 deletion_progress: None,
219 scan_progress: None,
220 scan_rx: None,
221 warnings: Vec::new(),
222 selected_warning: 0,
223 analyzing: false,
224 has_full_scan: false,
225 needs_redraw: true,
226 command_input: CommandInput::new(),
227 sort_mode: SortMode::default(),
228 search_state: SearchState::new(),
229 tab_manager: TabManager::with_initial_tab(path, 10),
230 preview_state: PreviewState::new(),
231 scan_on_startup,
232 user_settings,
233 settings_state: None,
234 cached_parent_tree: None,
235 pending_suspend_command: None,
236 scanned_cache: HashMap::new(),
237 };
238
239 app.update_cached_tree_len();
241 app.update_cached_miller_len();
242 app.update_cached_parent();
244
245 app
246 }
247
248 pub async fn run(mut self, mut terminal: DefaultTerminal) -> AppResult<()> {
250 if self.scan_on_startup {
252 self.start_scan();
253 }
254 let period = Duration::from_millis(TICK_INTERVAL_MS);
257 let mut interval = tokio::time::interval(period);
258 let mut events = EventStream::new();
259
260 while self.mode != AppMode::Quit {
261 if self.needs_redraw {
262 terminal.draw(|frame| self.render(frame))?;
263 self.needs_redraw = false;
264 }
265
266 tokio::select! {
267 biased;
268
269 Some(Ok(event)) = events.next() => {
270 if let Event::Key(key_event) = event {
271 if key_event.kind == crossterm::event::KeyEventKind::Press {
272 if self.mode == AppMode::Command {
273 self.handle_command_input(key_event);
274 } else if self.mode == AppMode::Search {
275 self.handle_search_input(key_event);
276 } else if matches!(self.mode, AppMode::Renaming | AppMode::CreatingFile | AppMode::CreatingDirectory | AppMode::Taking | AppMode::GoingTo) {
277 self.handle_input_event(key_event);
278 } else if self.mode == AppMode::ConflictResolution {
279 self.handle_conflict_key(key_event);
280 } else {
281 let action = KeyAction::from_key_event(key_event);
282 self.handle_action(action);
283 }
284 }
285 }
286
287 while crossterm::event::poll(Duration::ZERO)? {
289 if let Ok(Event::Key(key_event)) = crossterm::event::read() {
290 if key_event.kind == crossterm::event::KeyEventKind::Press {
291 if self.mode == AppMode::Command {
292 self.handle_command_input(key_event);
293 } else if self.mode == AppMode::Search {
294 self.handle_search_input(key_event);
295 } else if matches!(self.mode, AppMode::Renaming | AppMode::CreatingFile | AppMode::CreatingDirectory | AppMode::Taking | AppMode::GoingTo) {
296 self.handle_input_event(key_event);
297 } else if self.mode == AppMode::ConflictResolution {
298 self.handle_conflict_key(key_event);
299 } else {
300 let action = KeyAction::from_key_event(key_event);
301 self.handle_action(action);
302 }
303 if self.mode == AppMode::Quit {
304 break;
305 }
306 }
307 }
308 }
309 self.needs_redraw = true;
310 }
311
312 Some(result) = async {
313 if let Some(rx) = &mut self.scan_rx {
314 rx.recv().await
315 } else {
316 std::future::pending().await
317 }
318 } => {
319 self.handle_scan_result(result);
320 self.needs_redraw = true;
321 }
322
323 _ = interval.tick() => {
324 }
326 }
327
328 if let Some(mut cmd) = self.pending_suspend_command.take() {
330 ratatui::restore();
332
333 let _ = cmd.status();
335
336 terminal = ratatui::init();
338 self.needs_redraw = true;
339 }
340 }
341
342 Ok(())
343 }
344
345 fn start_scan(&mut self) {
350 self.path = self.view_root.clone();
352
353 self.scan_progress = Some(ScanProgress::new());
354 self.duplicates = None;
357 self.age_report = None;
358 self.warnings.clear();
359 self.selected_warning = 0;
360 self.scan_rx = Some(scanning::start_scan(self.view_root.clone()));
364 }
365
366 fn handle_scan_result(&mut self, result: ScanResult) {
368 match result {
369 ScanResult::Progress(progress) => {
370 self.scan_progress = Some(progress);
371 }
372 ScanResult::Warning(warning) => {
373 self.warnings.push(warning);
374 }
375 ScanResult::PartialTree(tree) => {
376 let root_path = tree.root_path.clone();
379 self.tree = Some(tree);
380
381 if self.tree_state.expanded.is_empty() {
383 self.tree_state = TreeState::new(root_path.clone());
384 }
385 self.tree_state.expand(&root_path);
386 self.update_cached_tree_len();
387 self.update_cached_miller_len();
388
389 if self.tree_state.selected >= self.cached_tree_len && self.cached_tree_len > 0 {
391 self.tree_state.selected = self.cached_tree_len - 1;
392 }
393 }
394 ScanResult::ScanComplete(Ok(tree)) => {
395 let root_path = tree.root_path.clone();
396
397 for warning in &tree.warnings {
399 if !self.warnings.iter().any(|w| w.path == warning.path) {
400 self.warnings.push(warning.clone());
401 }
402 }
403
404 let tree_for_analysis = tree.clone();
405
406 self.scanned_cache.insert(root_path.clone(), tree.clone());
408
409 self.tree = Some(tree);
410
411 if self.tree_state.expanded.is_empty()
414 || !self.tree_state.expanded.iter().any(|p| p.starts_with(&root_path))
415 {
416 self.tree_state = TreeState::new(root_path);
417 } else {
418 self.tree_state.expand(&root_path);
420 }
421 self.update_cached_tree_len();
422 self.update_cached_miller_len();
423
424 self.update_cached_parent();
426
427 if self.tree_state.selected >= self.cached_tree_len && self.cached_tree_len > 0 {
429 self.tree_state.selected = self.cached_tree_len - 1;
430 }
431 self.mode = AppMode::Normal;
432 self.error = None;
433 self.scan_progress = None;
434 self.has_full_scan = true;
435
436 self.analyzing = true;
438 self.scan_rx = Some(scanning::start_analysis(tree_for_analysis));
439 }
440 ScanResult::ScanComplete(Err(e)) => {
441 self.error = Some(e.to_string());
442 self.mode = AppMode::Normal;
443 self.scan_progress = None;
444 self.scan_rx = None;
445 self.analyzing = false;
446 }
447 ScanResult::AnalysisComplete {
448 duplicates,
449 age_report,
450 } => {
451 self.duplicates = Some(duplicates);
452 self.age_report = Some(age_report);
453 self.analyzing = false;
454 self.scan_rx = None;
455 }
456 ScanResult::DeletionProgress(progress) => {
457 self.deletion_progress = Some(progress);
458 }
459 ScanResult::DeletionComplete {
460 deleted,
461 failed,
462 bytes_freed,
463 } => {
464 self.deletion_progress = None;
465 self.scan_rx = None;
466
467 let (success, msg) = deletion::format_deletion_result(deleted, failed, bytes_freed);
468 self.deletion_message = Some((success, msg));
469 self.mode = AppMode::Normal;
470
471 self.start_scan();
473 }
474 ScanResult::OperationProgress(progress) => {
475 self.operation_progress = Some(progress);
476 }
477 ScanResult::OperationConflict(conflict) => {
478 self.pending_conflict = Some(conflict);
479 self.mode = AppMode::ConflictResolution;
480 }
481 ScanResult::OperationComplete {
482 operation_type,
483 succeeded,
484 failed,
485 bytes_processed: _,
486 } => {
487 self.operation_progress = None;
488 self.scan_rx = None;
489
490 let action = match operation_type {
491 gravityfile_ops::OperationType::Copy => "Copied",
492 gravityfile_ops::OperationType::Move => "Moved",
493 gravityfile_ops::OperationType::Delete => "Deleted",
494 gravityfile_ops::OperationType::Rename => "Renamed",
495 gravityfile_ops::OperationType::CreateFile => "Created file",
496 gravityfile_ops::OperationType::CreateDirectory => "Created directory",
497 };
498
499 let success = failed == 0;
500 let msg = if success {
501 format!("{} {} items", action, succeeded)
502 } else {
503 format!("{} {} items, {} failed", action, succeeded, failed)
504 };
505 self.operation_message = Some((success, msg));
506 self.mode = AppMode::Normal;
507
508 self.start_scan();
510 }
511 }
512 }
513
514 fn update_cached_tree_len(&mut self) {
516 if let Some((node, root_path)) = self.get_view_root_node() {
517 let items = TreeView::new(node, &root_path, &self.theme, &self.marked, &self.clipboard).flatten(&self.tree_state);
518 self.cached_tree_len = items.len();
519 } else {
520 self.cached_tree_len = 0;
521 }
522 }
523
524 fn update_cached_miller_len(&mut self) {
526 if let Some((node, _)) = self.get_view_root_node() {
527 self.cached_miller_len = node.children.len();
528 } else {
529 self.cached_miller_len = 0;
530 }
531 }
532
533 fn sync_layout_selection(&mut self) {
536 let sync_data = self.collect_layout_sync_data();
538
539 let Some(data) = sync_data else {
540 self.layout_mode = self.layout_mode.toggle();
542 if self.layout_mode == LayoutMode::Miller {
543 self.miller_state.reset();
544 self.update_cached_miller_len();
545 self.update_preview();
546 } else {
547 self.update_cached_tree_len();
548 self.preview_state.content = crate::preview::PreviewContent::Empty;
549 }
550 return;
551 };
552
553 match self.layout_mode {
554 LayoutMode::Tree => {
555 self.layout_mode = LayoutMode::Miller;
557
558 if let Some(new_view_root) = data.new_view_root {
560 self.view_root = new_view_root;
561 self.tree_state.expand(&self.view_root);
562 }
563
564 if let Some(miller_idx) = data.miller_index {
565 self.miller_state.selected = miller_idx;
566 self.miller_state.offset = 0;
567 self.update_cached_miller_len();
568 self.miller_state.ensure_visible(20);
569 self.update_preview();
570 } else {
571 self.miller_state.reset();
573 self.update_cached_miller_len();
574 self.update_preview();
575 }
576 }
577 LayoutMode::Miller => {
578 self.layout_mode = LayoutMode::Tree;
580 self.preview_state.content = crate::preview::PreviewContent::Empty;
581
582 self.tree_state.expand(&data.view_path);
584
585 if let Some(tree_idx) = data.tree_index {
586 self.tree_state.selected = tree_idx;
587 self.tree_state.offset = 0;
588 self.tree_state.ensure_visible(20);
589 }
590 self.update_cached_tree_len();
591 }
592 }
593 }
594
595 fn collect_layout_sync_data(&self) -> Option<LayoutSyncData> {
597 let (view_node, view_path) = self.get_view_root_node()?;
598
599 match self.layout_mode {
600 LayoutMode::Tree => {
601 let items = TreeView::new(view_node, &view_path, &self.theme, &self.marked, &self.clipboard)
603 .flatten(&self.tree_state);
604
605 let selected_path = items.get(self.tree_state.selected).map(|item| item.path.clone());
606
607 let Some(selected) = selected_path else {
608 return Some(LayoutSyncData {
609 view_path: view_path.clone(),
610 miller_index: None,
611 tree_index: None,
612 new_view_root: None,
613 });
614 };
615
616 if selected == view_path {
618 let miller_index = if self.miller_state.selected < view_node.children.len() {
619 Some(self.miller_state.selected)
620 } else if view_node.children.is_empty() {
621 None
622 } else {
623 Some(0)
624 };
625 return Some(LayoutSyncData {
626 view_path: view_path.clone(),
627 miller_index,
628 tree_index: None,
629 new_view_root: None,
630 });
631 }
632
633 let is_direct_child = selected.parent() == Some(view_path.as_path());
635
636 if is_direct_child {
637 let name = selected.file_name()?.to_string_lossy();
639 let miller_index = view_node.children.iter().position(|c| c.name.as_str() == name);
640 Some(LayoutSyncData {
641 view_path: view_path.clone(),
642 miller_index,
643 tree_index: None,
644 new_view_root: None,
645 })
646 } else {
647 let new_view_root = selected.parent()?.to_path_buf();
650
651 let selected_name = selected.file_name()?.to_string_lossy().to_string();
653
654 let tree = self.tree.as_ref()?;
656 let new_view_node = Self::find_node_at_path(&tree.root, &new_view_root, &tree.root_path)?;
657 let miller_index = new_view_node.children.iter().position(|c| c.name.as_str() == selected_name);
658
659 Some(LayoutSyncData {
660 view_path: view_path.clone(),
661 miller_index,
662 tree_index: None,
663 new_view_root: Some(new_view_root),
664 })
665 }
666 }
667 LayoutMode::Miller => {
668 let target_path = view_node
670 .children
671 .get(self.miller_state.selected)
672 .map(|c| view_path.join(&*c.name));
673
674 let mut tree_state_for_search = self.tree_state.clone();
677 tree_state_for_search.expand(&view_path);
678
679 let items = TreeView::new(view_node, &view_path, &self.theme, &self.marked, &self.clipboard)
680 .flatten(&tree_state_for_search);
681
682 let tree_index = target_path.as_ref().and_then(|target| {
683 items.iter().position(|item| &item.path == target)
684 });
685
686 Some(LayoutSyncData {
687 view_path: view_path.clone(),
688 miller_index: None,
689 tree_index,
690 new_view_root: None,
691 })
692 }
693 }
694 }
695
696 fn update_preview(&mut self) {
698 if self.layout_mode != LayoutMode::Miller {
700 self.preview_state.content = crate::preview::PreviewContent::Empty;
701 return;
702 }
703
704 let Some((view_node, _)) = self.get_view_root_node() else {
705 self.preview_state.content = crate::preview::PreviewContent::Empty;
706 return;
707 };
708
709 let Some(selected_child) = view_node.children.get(self.miller_state.selected) else {
710 self.preview_state.content = crate::preview::PreviewContent::Empty;
711 return;
712 };
713
714 if selected_child.is_dir() {
716 self.preview_state.content = crate::preview::PreviewContent::Empty;
718 return;
719 }
720
721 let file_path = self.view_root.join(&*selected_child.name);
723
724 self.preview_state.update(Some(&file_path));
726 }
727
728 fn render(&self, frame: &mut Frame) {
730 frame.render_widget(self, frame.area());
731 }
732
733 fn handle_action(&mut self, action: KeyAction) {
735 self.deletion_message = None;
737
738 match self.mode {
740 AppMode::Help => {
741 match action {
742 KeyAction::ToggleHelp | KeyAction::Quit | KeyAction::Cancel => {
743 self.mode = AppMode::Normal;
744 }
745 _ => {}
746 }
747 return;
748 }
749 AppMode::Settings => {
750 self.handle_settings_input(action);
751 return;
752 }
753 AppMode::ConfirmDelete => {
754 match action {
755 KeyAction::Confirm | KeyAction::DrillDown => {
756 self.execute_deletion();
757 }
758 KeyAction::Quit | KeyAction::Cancel => {
759 self.marked.clear();
761 self.mode = AppMode::Normal;
762 }
763 _ => {}
764 }
765 return;
766 }
767 AppMode::Deleting => {
768 return;
769 }
770 AppMode::Command => {
771 match action {
772 KeyAction::Quit | KeyAction::Cancel => {
773 self.command_input.clear();
774 self.mode = AppMode::Normal;
775 }
776 _ => {}
777 }
778 return;
779 }
780 AppMode::ConflictResolution => {
781 return;
783 }
784 AppMode::Copying | AppMode::Moving => {
785 return;
787 }
788 _ => {}
789 }
790
791 match action {
792 KeyAction::Quit => {
793 self.mode = AppMode::Quit;
794 }
795 KeyAction::ForceQuit => {
796 self.mode = AppMode::Quit;
797 }
798 KeyAction::Cancel => {
799 if !self.clipboard.is_empty() {
801 self.clipboard.clear();
802 } else if !self.marked.is_empty() {
803 self.marked.clear();
804 }
805 }
807
808 KeyAction::NextView => {
809 self.view = self.view.next();
810 }
811 KeyAction::PrevView => {
812 self.view = self.view.prev();
813 }
814
815 KeyAction::NewDirTab => {
817 self.tab_manager.new_tab(self.view_root.clone());
819 }
820 KeyAction::CloseDirTab => {
821 self.tab_manager.close_active_tab();
822 }
823 KeyAction::NextDirTab => {
824 if self.tab_manager.len() > 1 {
826 self.tab_manager.next_tab();
827 self.sync_from_active_tab();
828 }
829 }
830 KeyAction::PrevDirTab => {
831 if self.tab_manager.len() > 1 {
833 self.tab_manager.prev_tab();
834 self.sync_from_active_tab();
835 }
836 }
837 KeyAction::DirTab(n) => {
838 if self.tab_manager.len() > 1 {
840 self.tab_manager.switch_to_number(n as usize);
841 self.sync_from_active_tab();
842 }
843 }
844
845 KeyAction::MoveUp => self.move_up(),
846 KeyAction::MoveDown => self.move_down(),
847 KeyAction::PageUp => self.page_up(),
848 KeyAction::PageDown => self.page_down(),
849 KeyAction::JumpToTop => self.jump_to_top(),
850 KeyAction::JumpToBottom => self.jump_to_bottom(),
851
852 KeyAction::MoveRight | KeyAction::Expand => {
853 if self.view == View::Explorer {
854 match self.layout_mode {
855 LayoutMode::Tree => self.expand_selected(),
856 LayoutMode::Miller => self.drill_into_miller_selected(),
857 }
858 } else if self.view == View::Duplicates {
859 if !self.duplicates_state.is_expanded(self.duplicates_state.selected_group) {
861 self.duplicates_state.toggle_expand();
862 }
863 }
864 }
865 KeyAction::MoveLeft | KeyAction::Collapse => {
866 if self.view == View::Explorer {
867 match self.layout_mode {
868 LayoutMode::Tree => self.collapse_selected(),
869 LayoutMode::Miller => self.navigate_back(),
870 }
871 } else if self.view == View::Duplicates {
872 if self.duplicates_state.is_expanded(self.duplicates_state.selected_group) {
874 self.duplicates_state.toggle_expand();
875 }
876 }
877 }
878 KeyAction::ToggleExpand => {
879 if self.view == View::Explorer && self.layout_mode == LayoutMode::Tree {
880 self.toggle_selected();
881 } else if self.view == View::Duplicates {
882 self.duplicates_state.toggle_expand();
883 }
884 }
885
886 KeyAction::ToggleMark => {
888 self.toggle_mark();
889 }
890 KeyAction::ClearMarks => {
891 self.marked.clear();
892 }
893
894 KeyAction::Yank => {
896 self.yank_selection();
897 }
898 KeyAction::Cut => {
899 self.cut_selection();
900 }
901 KeyAction::Paste => {
902 self.paste_clipboard();
903 }
904
905 KeyAction::Delete => {
907 if self.marked.is_empty() {
909 if self.view == View::Duplicates {
910 let paths_to_mark: Vec<PathBuf> = if let Some((filtered, _)) = self.get_filtered_duplicates() {
913 let is_header = !self.duplicates_state.is_expanded(self.duplicates_state.selected_group)
914 || self.duplicates_state.selected_item(self.duplicates_state.selected_group) == 0;
915
916 if is_header {
917 filtered
919 .get(self.duplicates_state.selected_group)
920 .map(|g| g.paths.iter().skip(1).cloned().collect())
921 .unwrap_or_default()
922 } else {
923 self.duplicates_state
925 .selected_file_path(&filtered)
926 .cloned()
927 .into_iter()
928 .collect()
929 }
930 } else {
931 Vec::new()
932 };
933 for path in paths_to_mark {
934 self.marked.insert(path);
935 }
936 } else if let Some(path) = self.get_selected_path() {
937 self.marked.insert(path);
938 }
939 }
940 if !self.marked.is_empty() {
941 self.mode = AppMode::ConfirmDelete;
942 }
943 }
944 KeyAction::Rename => {
945 self.start_rename();
946 }
947 KeyAction::CreateFile => {
948 self.start_create_file();
949 }
950 KeyAction::CreateDirectory => {
951 self.start_create_directory();
952 }
953 KeyAction::Take => {
954 self.start_take();
955 }
956 KeyAction::GoTo => {
957 self.start_goto();
958 }
959 KeyAction::Undo => {
960 self.execute_undo();
961 }
962
963 KeyAction::Confirm => {
964 if !self.marked.is_empty() {
965 self.mode = AppMode::ConfirmDelete;
966 } else if self.view == View::Explorer {
967 self.toggle_selected();
968 }
969 }
970
971 KeyAction::DrillDown | KeyAction::OpenFile => {
972 if self.view == View::Explorer {
973 let is_file = self.is_selected_file();
975 if is_file {
976 self.open_selected_file();
977 } else {
978 match self.layout_mode {
979 LayoutMode::Tree => self.drill_into_selected(),
980 LayoutMode::Miller => self.drill_into_miller_selected(),
981 }
982 }
983 }
984 }
985 KeyAction::NavigateBack => {
986 if self.view == View::Explorer {
987 self.navigate_back();
988 }
989 }
990
991 KeyAction::CommandMode => {
992 self.command_input.clear();
993 self.mode = AppMode::Command;
994 }
995
996 KeyAction::ToggleDetails => {
997 self.show_details = !self.show_details;
998 }
999 KeyAction::ToggleHelp => {
1000 self.mode = AppMode::Help;
1001 }
1002 KeyAction::ToggleTheme => {
1003 self.theme = self.theme.toggle();
1004 }
1005 KeyAction::ToggleLayout => {
1006 self.sync_layout_selection();
1007 }
1008 KeyAction::CyclePreviewMode => {
1009 self.preview_state.cycle_mode();
1010 }
1011 KeyAction::OpenSettings => {
1012 self.settings_state = Some(SettingsState::new(self.user_settings.clone()));
1013 self.mode = AppMode::Settings;
1014 }
1015 KeyAction::Refresh => {
1016 self.marked.clear();
1017 self.start_scan();
1018 }
1019 KeyAction::Sort => {
1020 self.cycle_sort_mode();
1021 }
1022 KeyAction::ReverseSort => {
1023 self.reverse_sort_mode();
1024 }
1025 KeyAction::Search => {
1026 self.start_search();
1027 }
1028
1029 _ => {}
1030 }
1031 }
1032
1033 fn toggle_mark(&mut self) {
1035 let path = match self.view {
1036 View::Explorer => {
1037 if let Some((node, root_path)) = self.get_view_root_node() {
1038 match self.layout_mode {
1039 LayoutMode::Tree => {
1040 let items =
1041 TreeView::new(node, &root_path, &self.theme, &self.marked, &self.clipboard).flatten(&self.tree_state);
1042 items.get(self.tree_state.selected).map(|i| i.path.clone())
1043 }
1044 LayoutMode::Miller => {
1045 node.children.get(self.miller_state.selected).map(|child| {
1046 self.view_root.join(&*child.name)
1047 })
1048 }
1049 }
1050 } else {
1051 None
1052 }
1053 }
1054 View::Duplicates => {
1055 let (is_on_header, group_paths, selected_file) = if let Some((filtered, _)) = self.get_filtered_duplicates() {
1057 let is_on_header = !self.duplicates_state.is_expanded(self.duplicates_state.selected_group)
1058 || self.duplicates_state.selected_item(self.duplicates_state.selected_group) == 0;
1059
1060 let group_paths: Vec<PathBuf> = filtered
1061 .get(self.duplicates_state.selected_group)
1062 .map(|g| g.paths.clone())
1063 .unwrap_or_default();
1064
1065 let selected_file = if !is_on_header {
1066 self.duplicates_state.selected_file_path(&filtered).cloned()
1067 } else {
1068 None
1069 };
1070
1071 (is_on_header, group_paths, selected_file)
1072 } else {
1073 return;
1074 };
1075
1076 if is_on_header {
1077 let any_marked = group_paths.iter().any(|p| self.marked.contains(p));
1079
1080 if any_marked {
1081 for path in &group_paths {
1083 self.marked.remove(path);
1084 }
1085 } else {
1086 for path in group_paths {
1088 if path != self.path {
1089 self.marked.insert(path);
1090 }
1091 }
1092 }
1093 return; } else {
1095 selected_file
1097 }
1098 }
1099 View::Age => {
1100 if let Some(filtered) = self.get_filtered_stale_dirs() {
1101 let selected = self.selected_stale_dir.min(filtered.len().saturating_sub(1));
1102 filtered.get(selected).map(|d| d.path.clone())
1103 } else {
1104 None
1105 }
1106 }
1107 View::Errors => None,
1108 };
1109
1110 if let Some(path) = path {
1111 if path != self.path {
1112 if self.marked.contains(&path) {
1113 self.marked.remove(&path);
1114 } else {
1115 self.marked.insert(path);
1116 }
1117 }
1118 }
1119 }
1120
1121 fn execute_deletion(&mut self) {
1123 let paths_with_sizes: Vec<(PathBuf, u64)> = self
1124 .marked
1125 .iter()
1126 .map(|p| (p.clone(), self.get_path_size(p).unwrap_or(0)))
1127 .collect();
1128
1129 let total = paths_with_sizes.len();
1130 if total == 0 {
1131 return;
1132 }
1133
1134 self.marked.clear();
1135 self.mode = AppMode::Deleting;
1136 self.deletion_progress = Some(DeletionProgress {
1137 total,
1138 deleted: 0,
1139 failed: 0,
1140 bytes_freed: 0,
1141 current: paths_with_sizes.first().map(|(p, _)| p.clone()),
1142 });
1143
1144 self.scan_rx = Some(deletion::start_deletion(paths_with_sizes));
1145 }
1146
1147 fn get_path_size(&self, target: &PathBuf) -> Option<u64> {
1149 let tree = self.tree.as_ref()?;
1150
1151 fn find_size(
1152 node: &gravityfile_core::FileNode,
1153 target: &PathBuf,
1154 current: &PathBuf,
1155 ) -> Option<u64> {
1156 if current == target {
1157 return Some(node.size);
1158 }
1159 for child in &node.children {
1160 let child_path = current.join(&*child.name);
1161 if let Some(size) = find_size(child, target, &child_path) {
1162 return Some(size);
1163 }
1164 }
1165 None
1166 }
1167
1168 find_size(&tree.root, target, &tree.root_path)
1169 }
1170
1171 fn yank_selection(&mut self) {
1173 let paths: Vec<PathBuf> = if self.marked.is_empty() {
1174 self.get_selected_path().into_iter().collect()
1175 } else {
1176 self.marked.iter().cloned().collect()
1177 };
1178
1179 if !paths.is_empty() {
1180 self.clipboard.yank(paths, self.view_root.clone());
1181 self.marked.clear();
1182 }
1183 }
1184
1185 fn cut_selection(&mut self) {
1187 let paths: Vec<PathBuf> = if self.marked.is_empty() {
1188 self.get_selected_path().into_iter().collect()
1189 } else {
1190 self.marked.iter().cloned().collect()
1191 };
1192
1193 if !paths.is_empty() {
1194 self.clipboard.cut(paths, self.view_root.clone());
1195 self.marked.clear();
1196 }
1197 }
1198
1199 fn paste_clipboard(&mut self) {
1203 self.paste_with_resolution(None);
1204 }
1205
1206 fn paste_with_resolution(&mut self, resolution: Option<gravityfile_ops::ConflictResolution>) {
1208 if self.clipboard.is_empty() {
1209 return;
1210 }
1211
1212 let destination = self.get_paste_destination();
1214
1215 let sources = self.clipboard.paths.clone();
1216 let mode = self.clipboard.mode;
1217
1218 if resolution.is_none() {
1220 if let Some(conflict) = self.check_paste_conflict(&sources, &destination) {
1221 self.pending_operation = Some(PendingOperation::Paste {
1223 sources: sources.clone(),
1224 destination: destination.clone(),
1225 mode,
1226 });
1227 self.pending_conflict = Some(conflict);
1228 self.mode = AppMode::ConflictResolution;
1229 return;
1230 }
1231 }
1232
1233 match mode {
1234 ClipboardMode::Copy => {
1235 self.mode = AppMode::Copying;
1236 let options = CopyOptions {
1237 conflict_resolution: resolution,
1238 preserve_timestamps: false,
1239 };
1240 let rx = gravityfile_ops::start_copy(sources, destination, options);
1241 self.scan_rx = Some(Self::adapt_copy_rx(rx));
1242 }
1243 ClipboardMode::Cut => {
1244 self.mode = AppMode::Moving;
1245 let options = MoveOptions {
1246 conflict_resolution: resolution,
1247 };
1248 let rx = gravityfile_ops::start_move(sources, destination, options);
1249 self.scan_rx = Some(Self::adapt_move_rx(rx));
1250 self.clipboard.clear();
1251 }
1252 ClipboardMode::Empty => {}
1253 }
1254 }
1255
1256 fn check_paste_conflict(&self, sources: &[PathBuf], destination: &PathBuf) -> Option<Conflict> {
1258 use gravityfile_ops::{ConflictKind};
1259
1260 for source in sources {
1261 if let Some(filename) = source.file_name() {
1262 let dest_path = destination.join(filename);
1263
1264 if dest_path.exists() {
1266 let kind = if source == &dest_path {
1268 ConflictKind::SameFile
1270 } else if dest_path.is_dir() {
1271 ConflictKind::DirectoryExists
1272 } else {
1273 ConflictKind::FileExists
1274 };
1275
1276 return Some(Conflict::new(
1277 source.clone(),
1278 dest_path,
1279 kind,
1280 ));
1281 }
1282 }
1283 }
1284 None
1285 }
1286
1287 fn get_paste_destination(&self) -> PathBuf {
1292 if let Some(selected_path) = self.get_selected_path() {
1294 if selected_path.is_dir() {
1296 return selected_path;
1297 } else {
1298 if let Some(parent) = selected_path.parent() {
1300 return parent.to_path_buf();
1301 }
1302 }
1303 }
1304 self.view_root.clone()
1306 }
1307
1308 fn adapt_copy_rx(mut rx: mpsc::Receiver<CopyResult>) -> mpsc::Receiver<ScanResult> {
1310 let (tx, result_rx) = mpsc::channel(100);
1311 tokio::spawn(async move {
1312 while let Some(result) = rx.recv().await {
1313 let scan_result = match result {
1314 CopyResult::Progress(p) => ScanResult::OperationProgress(p),
1315 CopyResult::Conflict(c) => ScanResult::OperationConflict(c),
1316 CopyResult::Complete(c) => ScanResult::OperationComplete {
1317 operation_type: c.operation_type,
1318 succeeded: c.succeeded,
1319 failed: c.failed,
1320 bytes_processed: c.bytes_processed,
1321 },
1322 };
1323 if tx.send(scan_result).await.is_err() {
1324 break;
1325 }
1326 }
1327 });
1328 result_rx
1329 }
1330
1331 fn adapt_move_rx(mut rx: mpsc::Receiver<MoveResult>) -> mpsc::Receiver<ScanResult> {
1333 let (tx, result_rx) = mpsc::channel(100);
1334 tokio::spawn(async move {
1335 while let Some(result) = rx.recv().await {
1336 let scan_result = match result {
1337 MoveResult::Progress(p) => ScanResult::OperationProgress(p),
1338 MoveResult::Conflict(c) => ScanResult::OperationConflict(c),
1339 MoveResult::Complete(c) => ScanResult::OperationComplete {
1340 operation_type: c.operation_type,
1341 succeeded: c.succeeded,
1342 failed: c.failed,
1343 bytes_processed: c.bytes_processed,
1344 },
1345 };
1346 if tx.send(scan_result).await.is_err() {
1347 break;
1348 }
1349 }
1350 });
1351 result_rx
1352 }
1353
1354 fn handle_conflict_key(&mut self, event: crossterm::event::KeyEvent) {
1356 use crossterm::event::KeyCode;
1357 use gravityfile_ops::ConflictResolution;
1358
1359 let is_same_file = self.pending_conflict
1361 .as_ref()
1362 .map(|c| matches!(c.kind, gravityfile_ops::ConflictKind::SameFile))
1363 .unwrap_or(false);
1364
1365 let resolution = match event.code {
1366 KeyCode::Char('s') => Some(ConflictResolution::Skip),
1368 KeyCode::Char('S') if !is_same_file => Some(ConflictResolution::SkipAll),
1370 KeyCode::Char('o') if !is_same_file => Some(ConflictResolution::Overwrite),
1372 KeyCode::Char('O') if !is_same_file => Some(ConflictResolution::OverwriteAll),
1374 KeyCode::Char('r') => Some(ConflictResolution::AutoRename),
1376 KeyCode::Esc => Some(ConflictResolution::Abort),
1378 KeyCode::Enter if is_same_file => Some(ConflictResolution::Skip),
1380 KeyCode::Enter => {
1382 if let Some(conflict) = &self.pending_conflict {
1383 if matches!(conflict.kind, gravityfile_ops::ConflictKind::SourceIsAncestor) {
1384 Some(ConflictResolution::Abort)
1385 } else {
1386 None
1387 }
1388 } else {
1389 None
1390 }
1391 }
1392 _ => None,
1393 };
1394
1395 if let Some(res) = resolution {
1396 self.pending_conflict = None;
1398
1399 let pending = self.pending_operation.take();
1401
1402 if matches!(res, ConflictResolution::Abort | ConflictResolution::Skip | ConflictResolution::SkipAll) {
1404 self.mode = AppMode::Normal;
1405 self.scan_rx = None;
1406 self.operation_progress = None;
1407 return;
1410 }
1411
1412 if let Some(pending_op) = pending {
1414 match pending_op {
1415 PendingOperation::Paste { sources, destination: _, mode } => {
1416 let old_clipboard = self.clipboard.clone();
1418 self.clipboard.paths = sources;
1419 self.clipboard.mode = mode;
1420
1421 self.paste_with_resolution(Some(res));
1423
1424 if mode == ClipboardMode::Copy {
1427 self.clipboard = old_clipboard;
1428 }
1429 }
1430 }
1431 } else {
1432 self.mode = AppMode::Normal;
1434 self.scan_rx = None;
1435 self.operation_progress = None;
1436 }
1437 }
1438 }
1439
1440 fn start_rename(&mut self) {
1442 if let Some(path) = self.get_selected_path() {
1443 let name = path
1444 .file_name()
1445 .map(|n| n.to_string_lossy().to_string())
1446 .unwrap_or_default();
1447 let mut input = InputState::with_initial(&name);
1448 input.context_path = Some(path);
1449 self.input_state = Some(input);
1450 self.mode = AppMode::Renaming;
1451 }
1452 }
1453
1454 fn start_create_file(&mut self) {
1456 let mut input = InputState::new();
1457 input.context_path = Some(self.view_root.clone());
1458 self.input_state = Some(input);
1459 self.mode = AppMode::CreatingFile;
1460 }
1461
1462 fn start_create_directory(&mut self) {
1464 let mut input = InputState::new();
1465 input.context_path = Some(self.view_root.clone());
1466 self.input_state = Some(input);
1467 self.mode = AppMode::CreatingDirectory;
1468 }
1469
1470 fn start_take(&mut self) {
1472 let mut input = InputState::new();
1473 input.context_path = Some(self.view_root.clone());
1474 self.input_state = Some(input);
1475 self.mode = AppMode::Taking;
1476 }
1477
1478 fn start_goto(&mut self) {
1480 let current_path = self.view_root.to_string_lossy().to_string();
1482 let mut input = InputState::with_initial(¤t_path);
1483 input.context_path = Some(self.view_root.clone());
1484 self.input_state = Some(input);
1485 self.mode = AppMode::GoingTo;
1486 }
1487
1488 fn execute_goto(&mut self, path_str: &str) {
1490 let path = if path_str.starts_with('~') {
1491 if let Some(home) = dirs::home_dir() {
1493 home.join(&path_str[1..].trim_start_matches('/'))
1494 } else {
1495 std::path::PathBuf::from(path_str)
1496 }
1497 } else if path_str.starts_with('/') {
1498 std::path::PathBuf::from(path_str)
1499 } else {
1500 self.view_root.join(path_str)
1502 };
1503
1504 let path = path.canonicalize().unwrap_or(path);
1506
1507 if path.is_dir() {
1508 let within_scanned_tree = if let Some(tree) = &self.tree {
1510 path.starts_with(&tree.root_path) || tree.root_path.starts_with(&path)
1511 } else {
1512 false
1513 };
1514
1515 if within_scanned_tree {
1516 self.view_root = path.clone();
1518 self.view_history.clear();
1519 self.forward_history.clear();
1520 self.tree_state.selected = 0;
1521 self.tree_state.offset = 0;
1522 self.tree_state.expand(&self.view_root);
1523 self.miller_state.reset();
1524 self.update_cached_tree_len();
1525 self.update_cached_miller_len();
1526 self.preview_state.content = crate::preview::PreviewContent::Empty;
1527
1528 if let Some(tab) = self.tab_manager.active_tab_mut() {
1530 tab.view_root = self.view_root.clone();
1531 }
1532 } else {
1533 self.path = path.clone();
1536 self.view_root = path.clone();
1537 self.view_history.clear();
1538 self.forward_history.clear();
1539 self.tree_state = TreeState::new(self.path.clone());
1540 self.miller_state.reset();
1541 self.marked.clear();
1542 self.clipboard.clear();
1543 self.duplicates = None;
1544 self.age_report = None;
1545 self.warnings.clear();
1546 self.preview_state.content = crate::preview::PreviewContent::Empty;
1547 self.scan_progress = None;
1548
1549 self.tree = gravityfile_scan::quick_list(&path).ok();
1551 self.update_cached_tree_len();
1552 self.update_cached_miller_len();
1553
1554 if let Some(tab) = self.tab_manager.active_tab_mut() {
1556 tab.path = self.path.clone();
1557 tab.view_root = self.view_root.clone();
1558 tab.label = self.path
1559 .file_name()
1560 .map(|n| n.to_string_lossy().to_string())
1561 .unwrap_or_else(|| self.path.to_string_lossy().to_string());
1562 tab.history.clear();
1563 }
1564
1565 }
1567 } else {
1568 self.error = Some(format!("Not a directory: {}", path.display()));
1569 }
1570 }
1571
1572 fn execute_undo(&mut self) {
1574 self.operation_message = Some((false, "Undo not yet implemented".to_string()));
1577 }
1578
1579 fn handle_input_event(&mut self, key: crossterm::event::KeyEvent) {
1581 let current_mode = self.mode;
1582
1583 if let Some(ref mut input) = self.input_state {
1584 match input.handle_key(key) {
1585 InputResult::Continue => {}
1586 InputResult::Cancel => {
1587 self.input_state = None;
1588 self.mode = AppMode::Normal;
1589 }
1590 InputResult::Submit(value) => {
1591 let context_path = input.context_path.clone();
1592 self.input_state = None;
1593
1594 match current_mode {
1595 AppMode::Renaming => {
1596 if let Some(source) = context_path {
1597 self.execute_rename(source, value);
1598 }
1599 }
1600 AppMode::CreatingFile => {
1601 if let Some(parent) = context_path {
1602 self.execute_create_file(parent, value);
1603 }
1604 }
1605 AppMode::CreatingDirectory => {
1606 if let Some(parent) = context_path {
1607 self.execute_create_directory(parent, value);
1608 }
1609 }
1610 AppMode::Taking => {
1611 if let Some(parent) = context_path {
1612 self.execute_take(parent, value);
1613 }
1614 }
1615 AppMode::GoingTo => {
1616 self.execute_goto(&value);
1617 }
1618 _ => {}
1619 }
1620
1621 self.mode = AppMode::Normal;
1622 }
1623 }
1624 } else {
1625 self.mode = AppMode::Normal;
1627 }
1628 }
1629
1630 fn execute_rename(&mut self, source: std::path::PathBuf, new_name: String) {
1632 use gravityfile_ops::start_rename;
1633
1634 if new_name.is_empty() || new_name.contains('/') || new_name.contains('\\') {
1636 self.operation_message = Some((false, "Invalid file name".to_string()));
1637 return;
1638 }
1639
1640 let rx = start_rename(source.clone(), new_name.clone());
1641
1642 let adapted_rx = Self::adapt_rename_rx(rx);
1644 self.scan_rx = Some(adapted_rx);
1645 self.mode = AppMode::Normal;
1646
1647 self.operation_message = Some((true, format!("Renaming to {}...", new_name)));
1649 }
1650
1651 fn adapt_rename_rx(
1653 mut rx: mpsc::Receiver<gravityfile_ops::RenameResult>,
1654 ) -> mpsc::Receiver<ScanResult> {
1655 let (tx, adapted_rx) = mpsc::channel(100);
1656
1657 tokio::spawn(async move {
1658 while let Some(result) = rx.recv().await {
1659 let scan_result = match result {
1660 gravityfile_ops::RenameResult::Progress(progress) => {
1661 ScanResult::OperationProgress(progress)
1662 }
1663 gravityfile_ops::RenameResult::Complete(complete) => {
1664 ScanResult::OperationComplete {
1665 operation_type: complete.operation_type,
1666 succeeded: complete.succeeded,
1667 failed: complete.failed,
1668 bytes_processed: complete.bytes_processed,
1669 }
1670 }
1671 };
1672 if tx.send(scan_result).await.is_err() {
1673 break;
1674 }
1675 }
1676 });
1677
1678 adapted_rx
1679 }
1680
1681 fn execute_create_file(&mut self, parent: std::path::PathBuf, name: String) {
1683 use gravityfile_ops::start_create_file;
1684
1685 if name.is_empty() || name.contains('/') || name.contains('\\') {
1687 self.operation_message = Some((false, "Invalid file name".to_string()));
1688 return;
1689 }
1690
1691 let path = parent.join(&name);
1692 let rx = start_create_file(path.clone());
1693
1694 let adapted_rx = Self::adapt_create_rx(rx);
1695 self.scan_rx = Some(adapted_rx);
1696
1697 self.operation_message = Some((true, format!("Creating file {}...", name)));
1698 }
1699
1700 fn execute_create_directory(&mut self, parent: std::path::PathBuf, name: String) {
1702 use gravityfile_ops::start_create_directory;
1703
1704 if name.is_empty() || name.contains('/') || name.contains('\\') {
1706 self.operation_message = Some((false, "Invalid directory name".to_string()));
1707 return;
1708 }
1709
1710 let path = parent.join(&name);
1711 let rx = start_create_directory(path.clone());
1712
1713 let adapted_rx = Self::adapt_create_rx(rx);
1714 self.scan_rx = Some(adapted_rx);
1715
1716 self.operation_message = Some((true, format!("Creating directory {}...", name)));
1717 }
1718
1719 fn execute_take(&mut self, parent: std::path::PathBuf, name: String) {
1721 if name.is_empty() || name.contains('/') || name.contains('\\') {
1723 self.operation_message = Some((false, "Invalid directory name".to_string()));
1724 return;
1725 }
1726
1727 let path = parent.join(&name);
1728
1729 if path.exists() {
1731 if path.is_dir() {
1732 self.operation_message = Some((true, format!("Directory {} already exists, navigating...", name)));
1734 } else {
1735 self.operation_message = Some((false, format!("A file named {} already exists", name)));
1737 return;
1738 }
1739 } else {
1740 if let Err(e) = std::fs::create_dir(&path) {
1742 self.operation_message = Some((false, format!("Failed to create directory: {}", e)));
1743 return;
1744 }
1745 self.operation_message = Some((true, format!("Created and entered {}", name)));
1746 }
1747
1748 let saved_expanded = self.tree_state.expanded.clone();
1751 let saved_selected = match self.layout_mode {
1752 LayoutMode::Tree => self.tree_state.selected,
1753 LayoutMode::Miller => self.miller_state.selected,
1754 };
1755 self.view_history.push((self.view_root.clone(), saved_selected, saved_expanded));
1756
1757 self.view_root = path;
1759
1760 match self.layout_mode {
1762 LayoutMode::Tree => {
1763 self.tree_state.selected = 0;
1764 self.tree_state.offset = 0;
1765 self.tree_state.expand(&self.view_root);
1766 }
1767 LayoutMode::Miller => {
1768 self.miller_state.reset();
1769 }
1770 }
1771
1772 self.sync_to_active_tab();
1773
1774 self.start_scan();
1776 }
1777
1778 fn adapt_create_rx(
1780 mut rx: mpsc::Receiver<gravityfile_ops::CreateResult>,
1781 ) -> mpsc::Receiver<ScanResult> {
1782 let (tx, adapted_rx) = mpsc::channel(100);
1783
1784 tokio::spawn(async move {
1785 while let Some(result) = rx.recv().await {
1786 let scan_result = match result {
1787 gravityfile_ops::CreateResult::Progress(progress) => {
1788 ScanResult::OperationProgress(progress)
1789 }
1790 gravityfile_ops::CreateResult::Complete(complete) => {
1791 ScanResult::OperationComplete {
1792 operation_type: complete.operation_type,
1793 succeeded: complete.succeeded,
1794 failed: complete.failed,
1795 bytes_processed: complete.bytes_processed,
1796 }
1797 }
1798 };
1799 if tx.send(scan_result).await.is_err() {
1800 break;
1801 }
1802 }
1803 });
1804
1805 adapted_rx
1806 }
1807
1808 fn move_up(&mut self) {
1810 match self.view {
1811 View::Explorer => match self.layout_mode {
1812 LayoutMode::Tree => self.tree_state.move_up(1),
1813 LayoutMode::Miller => {
1814 self.miller_state.move_up(1);
1815 self.update_preview();
1816 }
1817 },
1818 View::Duplicates => {
1819 let (group_count, file_counts) = if let Some((filtered, _)) = self.get_filtered_duplicates() {
1821 let counts: Vec<usize> = filtered.iter().map(|g| g.paths.len()).collect();
1822 (filtered.len(), counts)
1823 } else {
1824 (0, Vec::new())
1825 };
1826 if group_count > 0 {
1827 self.duplicates_state.move_up(group_count, |idx| {
1828 file_counts.get(idx).copied().unwrap_or(0)
1829 });
1830 }
1831 }
1832 View::Age => {
1833 self.selected_stale_dir = self.selected_stale_dir.saturating_sub(1);
1834 }
1835 View::Errors => {
1836 self.selected_warning = self.selected_warning.saturating_sub(1);
1837 }
1838 }
1839 }
1840
1841 fn move_down(&mut self) {
1842 match self.view {
1843 View::Explorer => match self.layout_mode {
1844 LayoutMode::Tree => {
1845 self.tree_state.move_down(1, self.cached_tree_len);
1846 }
1847 LayoutMode::Miller => {
1848 self.miller_state.move_down(1, self.cached_miller_len);
1849 self.update_preview();
1850 }
1851 },
1852 View::Duplicates => {
1853 let (group_count, file_counts) = if let Some((filtered, _)) = self.get_filtered_duplicates() {
1855 let counts: Vec<usize> = filtered.iter().map(|g| g.paths.len()).collect();
1856 (filtered.len(), counts)
1857 } else {
1858 (0, Vec::new())
1859 };
1860 if group_count > 0 {
1861 self.duplicates_state.move_down(group_count, |idx| {
1862 file_counts.get(idx).copied().unwrap_or(0)
1863 });
1864 }
1865 }
1866 View::Age => {
1867 if let Some(filtered) = self.get_filtered_stale_dirs() {
1868 let max = filtered.len().saturating_sub(1);
1869 self.selected_stale_dir = (self.selected_stale_dir + 1).min(max);
1870 }
1871 }
1872 View::Errors => {
1873 let max = self.warnings.len().saturating_sub(1);
1874 self.selected_warning = (self.selected_warning + 1).min(max);
1875 }
1876 }
1877 }
1878
1879 fn page_up(&mut self) {
1880 match self.view {
1881 View::Explorer => match self.layout_mode {
1882 LayoutMode::Tree => self.tree_state.move_up(PAGE_SIZE),
1883 LayoutMode::Miller => {
1884 self.miller_state.move_up(PAGE_SIZE);
1885 self.update_preview();
1886 }
1887 },
1888 View::Duplicates => {
1889 self.duplicates_state.selected_group =
1891 self.duplicates_state.selected_group.saturating_sub(PAGE_SIZE);
1892 }
1893 View::Age => {
1894 self.selected_stale_dir = self.selected_stale_dir.saturating_sub(PAGE_SIZE);
1895 }
1896 View::Errors => {
1897 self.selected_warning = self.selected_warning.saturating_sub(PAGE_SIZE);
1898 }
1899 }
1900 }
1901
1902 fn page_down(&mut self) {
1903 match self.view {
1904 View::Explorer => match self.layout_mode {
1905 LayoutMode::Tree => {
1906 self.tree_state.move_down(PAGE_SIZE, self.cached_tree_len);
1907 }
1908 LayoutMode::Miller => {
1909 self.miller_state.move_down(PAGE_SIZE, self.cached_miller_len);
1910 self.update_preview();
1911 }
1912 },
1913 View::Duplicates => {
1914 if let Some((filtered, _)) = self.get_filtered_duplicates() {
1916 let max = filtered.len().saturating_sub(1);
1917 self.duplicates_state.selected_group =
1918 (self.duplicates_state.selected_group + PAGE_SIZE).min(max);
1919 }
1920 }
1921 View::Age => {
1922 if let Some(filtered) = self.get_filtered_stale_dirs() {
1923 let max = filtered.len().saturating_sub(1);
1924 self.selected_stale_dir = (self.selected_stale_dir + PAGE_SIZE).min(max);
1925 }
1926 }
1927 View::Errors => {
1928 let max = self.warnings.len().saturating_sub(1);
1929 self.selected_warning = (self.selected_warning + PAGE_SIZE).min(max);
1930 }
1931 }
1932 }
1933
1934 fn jump_to_top(&mut self) {
1935 match self.view {
1936 View::Explorer => match self.layout_mode {
1937 LayoutMode::Tree => self.tree_state.jump_to_top(),
1938 LayoutMode::Miller => {
1939 self.miller_state.jump_to_top();
1940 self.update_preview();
1941 }
1942 },
1943 View::Duplicates => self.duplicates_state.reset(),
1944 View::Age => self.selected_stale_dir = 0,
1945 View::Errors => self.selected_warning = 0,
1946 }
1947 }
1948
1949 fn jump_to_bottom(&mut self) {
1950 match self.view {
1951 View::Explorer => match self.layout_mode {
1952 LayoutMode::Tree => {
1953 self.tree_state.jump_to_bottom(self.cached_tree_len);
1954 }
1955 LayoutMode::Miller => {
1956 self.miller_state.jump_to_bottom(self.cached_miller_len);
1957 self.update_preview();
1958 }
1959 },
1960 View::Duplicates => {
1961 if let Some((filtered, _)) = self.get_filtered_duplicates() {
1962 self.duplicates_state.selected_group = filtered.len().saturating_sub(1);
1963 }
1964 }
1965 View::Age => {
1966 if let Some(filtered) = self.get_filtered_stale_dirs() {
1967 self.selected_stale_dir = filtered.len().saturating_sub(1);
1968 }
1969 }
1970 View::Errors => {
1971 self.selected_warning = self.warnings.len().saturating_sub(1);
1972 }
1973 }
1974 }
1975
1976 fn expand_selected(&mut self) {
1977 if let Some((node, root_path)) = self.get_view_root_node() {
1978 let items = TreeView::new(node, &root_path, &self.theme, &self.marked, &self.clipboard).flatten(&self.tree_state);
1979 if let Some(item) = items.get(self.tree_state.selected) {
1980 if matches!(item.node.kind, crate::ui::VisibleNodeKind::Directory { .. }) {
1982 let needs_lazy_load = if let Some(tree) = &self.tree {
1983 if let Some(node) = Self::find_node_at_path(&tree.root, &item.path, &tree.root_path) {
1984 node.children.is_empty()
1985 } else {
1986 false
1987 }
1988 } else {
1989 false
1990 };
1991 if needs_lazy_load {
1992 self.lazy_load_directory(&item.path);
1993 }
1994 }
1995 self.tree_state.expand(&item.path);
1996 self.update_cached_tree_len();
1997 }
1998 }
1999 }
2000
2001 fn collapse_selected(&mut self) {
2004 if let Some((node, root_path)) = self.get_view_root_node() {
2005 let items = TreeView::new(node, &root_path, &self.theme, &self.marked, &self.clipboard).flatten(&self.tree_state);
2006 if let Some(item) = items.get(self.tree_state.selected) {
2007 let is_expanded_dir = matches!(
2009 item.node.kind,
2010 crate::ui::VisibleNodeKind::Directory { expanded: true }
2011 );
2012
2013 if is_expanded_dir {
2014 self.tree_state.collapse(&item.path);
2016 self.update_cached_tree_len();
2017 } else {
2018 self.navigate_back();
2020 }
2021 }
2022 }
2023 }
2024
2025 fn toggle_selected(&mut self) {
2026 if let Some((node, root_path)) = self.get_view_root_node() {
2027 let items = TreeView::new(node, &root_path, &self.theme, &self.marked, &self.clipboard).flatten(&self.tree_state);
2028 if let Some(item) = items.get(self.tree_state.selected) {
2029 self.tree_state.toggle_expand(&item.path);
2030 self.update_cached_tree_len();
2031 }
2032 }
2033 }
2034
2035 fn get_selected_info(&self) -> Option<SelectedInfo> {
2036 let tree = self.tree.as_ref()?;
2037 let (view_node, view_path) = self.get_view_root_node()?;
2038
2039 match self.layout_mode {
2040 LayoutMode::Tree => {
2041 let items = TreeView::new(view_node, &view_path, &self.theme, &self.marked, &self.clipboard).flatten(&self.tree_state);
2042 let item = items.get(self.tree_state.selected)?;
2043 let node = Self::find_node_at_path(&tree.root, &item.path, &tree.root_path)?;
2044
2045 Some(SelectedInfo {
2046 name: item.node.name.clone(),
2047 path: item.path.clone(),
2048 size: node.size,
2049 file_count: node.file_count(),
2050 dir_count: node.dir_count(),
2051 modified: node.timestamps.modified,
2052 is_dir: node.is_dir(),
2053 })
2054 }
2055 LayoutMode::Miller => {
2056 let child = view_node.children.get(self.miller_state.selected)?;
2057 let path = self.view_root.join(&*child.name);
2058
2059 Some(SelectedInfo {
2060 name: child.name.to_string().into(),
2061 path,
2062 size: child.size,
2063 file_count: child.file_count(),
2064 dir_count: child.dir_count(),
2065 modified: child.timestamps.modified,
2066 is_dir: child.is_dir(),
2067 })
2068 }
2069 }
2070 }
2071
2072 fn is_selected_file(&self) -> bool {
2074 match self.layout_mode {
2075 LayoutMode::Tree => {
2076 if let Some((view_node, view_path)) = self.get_view_root_node() {
2077 let items = TreeView::new(view_node, &view_path, &self.theme, &self.marked, &self.clipboard)
2078 .flatten(&self.tree_state);
2079 if let Some(item) = items.get(self.tree_state.selected) {
2080 return !matches!(item.node.kind, crate::ui::VisibleNodeKind::Directory { .. });
2081 }
2082 }
2083 false
2084 }
2085 LayoutMode::Miller => {
2086 if let Some((view_node, _)) = self.get_view_root_node() {
2087 if let Some(child) = view_node.children.get(self.miller_state.selected) {
2088 return !child.is_dir();
2089 }
2090 }
2091 false
2092 }
2093 }
2094 }
2095
2096 fn get_selected_path(&self) -> Option<PathBuf> {
2098 match self.layout_mode {
2099 LayoutMode::Tree => {
2100 if let Some((view_node, view_path)) = self.get_view_root_node() {
2101 let items = TreeView::new(view_node, &view_path, &self.theme, &self.marked, &self.clipboard)
2102 .flatten(&self.tree_state);
2103 return items.get(self.tree_state.selected).map(|item| item.path.clone());
2104 }
2105 None
2106 }
2107 LayoutMode::Miller => {
2108 if let Some((view_node, _)) = self.get_view_root_node() {
2109 if let Some(child) = view_node.children.get(self.miller_state.selected) {
2110 return Some(self.view_root.join(&*child.name));
2111 }
2112 }
2113 None
2114 }
2115 }
2116 }
2117
2118 fn open_selected_file(&mut self) {
2120 let Some(path) = self.get_selected_path() else {
2121 return;
2122 };
2123
2124 let openers = &self.user_settings.openers;
2126 let editor_config = &self.user_settings.editor;
2127
2128 match crate::opener::open_file(&path, openers, editor_config) {
2129 crate::opener::OpenResult::Opened => {
2130 }
2132 crate::opener::OpenResult::NeedsSuspend(cmd) => {
2133 self.suspend_and_run(cmd);
2135 }
2136 crate::opener::OpenResult::NotSupported(msg) => {
2137 self.error = Some(msg);
2138 }
2139 crate::opener::OpenResult::Error(msg) => {
2140 self.error = Some(msg);
2141 }
2142 }
2143 }
2144
2145 fn suspend_and_run(&mut self, cmd: std::process::Command) {
2147 self.pending_suspend_command = Some(cmd);
2151 }
2152
2153 fn drill_into_selected(&mut self) {
2155 let Some((view_node, view_path)) = self.get_view_root_node() else {
2156 return;
2157 };
2158
2159 let items = TreeView::new(view_node, &view_path, &self.theme, &self.marked, &self.clipboard).flatten(&self.tree_state);
2160 let Some(item) = items.get(self.tree_state.selected) else {
2161 return;
2162 };
2163
2164 if !matches!(
2165 item.node.kind,
2166 crate::ui::VisibleNodeKind::Directory { .. }
2167 ) {
2168 return;
2169 }
2170
2171 let needs_lazy_load = if let Some(tree) = &self.tree {
2173 if let Some(node) = Self::find_node_at_path(&tree.root, &item.path, &tree.root_path) {
2174 node.children.is_empty()
2175 } else {
2176 true
2177 }
2178 } else {
2179 true
2180 };
2181
2182 if needs_lazy_load {
2184 self.lazy_load_directory(&item.path);
2185 }
2186
2187 if item.path == self.view_root {
2188 self.tree_state.expand(&item.path);
2189 self.update_cached_tree_len();
2190 return;
2191 }
2192
2193 let saved_expanded = self.tree_state.expanded.clone();
2194 let saved_selected = self.tree_state.selected;
2195 self.view_history
2196 .push((self.view_root.clone(), saved_selected, saved_expanded));
2197
2198 let target_path = item.path.clone();
2199
2200 if let Some(forward_idx) = self.forward_history.iter().rposition(|(p, _, _)| p == &target_path) {
2202 let (_, fwd_selected, fwd_expanded) = self.forward_history.remove(forward_idx);
2203 self.forward_history.truncate(forward_idx);
2205
2206 self.view_root = target_path;
2207 self.tree_state.selected = fwd_selected;
2208 self.tree_state.offset = 0;
2209 self.tree_state.expanded = fwd_expanded;
2210 self.tree_state.expand(&self.view_root);
2211 self.update_cached_tree_len();
2212 self.sync_to_active_tab();
2213 return;
2214 }
2215
2216 self.forward_history.clear();
2218
2219 self.view_root = target_path;
2220
2221 self.tree_state.selected = 0;
2222 self.tree_state.offset = 0;
2223 self.tree_state.expand(&self.view_root);
2224 self.update_cached_tree_len();
2225 self.sync_to_active_tab();
2226 }
2227
2228 fn drill_into_miller_selected(&mut self) {
2230 let Some((view_node, _)) = self.get_view_root_node() else {
2231 return;
2232 };
2233
2234 let Some(selected_child) = view_node.children.get(self.miller_state.selected) else {
2235 return;
2236 };
2237
2238 if !selected_child.is_dir() {
2240 return;
2241 }
2242
2243 let target_path = self.view_root.join(&*selected_child.name);
2244
2245 if selected_child.children.is_empty() {
2247 self.lazy_load_directory(&target_path);
2248 }
2249
2250 let saved_expanded = self.tree_state.expanded.clone();
2252 let saved_selected = self.miller_state.selected;
2253 self.view_history
2254 .push((self.view_root.clone(), saved_selected, saved_expanded));
2255
2256 if let Some(forward_idx) = self.forward_history.iter().rposition(|(p, _, _)| p == &target_path) {
2258 let (_, fwd_selected, fwd_expanded) = self.forward_history.remove(forward_idx);
2259 self.forward_history.truncate(forward_idx);
2261
2262 self.view_root = target_path;
2263 self.miller_state.selected = fwd_selected;
2264 self.miller_state.offset = 0;
2265 self.tree_state.expanded = fwd_expanded;
2266 self.update_cached_miller_len();
2267 self.miller_state.ensure_visible(20);
2268 self.update_preview();
2269 self.sync_to_active_tab();
2270 return;
2271 }
2272
2273 self.forward_history.clear();
2275
2276 self.view_root = target_path;
2278 self.miller_state.reset();
2279 self.update_cached_miller_len();
2280 self.update_preview();
2281 self.sync_to_active_tab();
2282 }
2283
2284 fn lazy_load_directory(&mut self, path: &PathBuf) {
2287 if let Some(cached_tree) = self.scanned_cache.get(path).cloned() {
2289 if let Some(tree) = &mut self.tree {
2291 if let Some(node) = Self::find_node_mut(&mut tree.root, path, &tree.root_path) {
2292 *node = cached_tree.root;
2294 }
2295 }
2296 return;
2297 }
2298
2299 if let Ok(quick_tree) = gravityfile_scan::quick_list(path) {
2302 if let Some(tree) = &mut self.tree {
2304 if let Some(node) = Self::find_node_mut(&mut tree.root, path, &tree.root_path) {
2305 node.children = quick_tree.root.children;
2306 }
2307 }
2308 }
2309 }
2310
2311 fn find_node_mut<'a>(
2313 node: &'a mut gravityfile_core::FileNode,
2314 target: &PathBuf,
2315 current_path: &PathBuf,
2316 ) -> Option<&'a mut gravityfile_core::FileNode> {
2317 if current_path == target {
2318 return Some(node);
2319 }
2320
2321 for child in &mut node.children {
2322 let child_path = current_path.join(&*child.name);
2323 if target.starts_with(&child_path) {
2324 if let Some(found) = Self::find_node_mut(child, target, &child_path) {
2325 return Some(found);
2326 }
2327 }
2328 }
2329
2330 None
2331 }
2332
2333 fn navigate_back(&mut self) {
2336 let current_selected = match self.layout_mode {
2338 LayoutMode::Tree => self.tree_state.selected,
2339 LayoutMode::Miller => self.miller_state.selected,
2340 };
2341 let current_state = (
2342 self.view_root.clone(),
2343 current_selected,
2344 self.tree_state.expanded.clone(),
2345 );
2346
2347 if let Some((prev_root, saved_selected, saved_expanded)) = self.view_history.pop() {
2349 self.forward_history.push(current_state);
2351 self.view_root = prev_root;
2352 self.tree_state.expanded = saved_expanded;
2353
2354 match self.layout_mode {
2356 LayoutMode::Tree => {
2357 self.tree_state.selected = saved_selected;
2358 self.tree_state.offset = 0;
2359 self.update_cached_tree_len();
2360 }
2361 LayoutMode::Miller => {
2362 self.miller_state.selected = saved_selected;
2363 self.miller_state.offset = 0;
2364 self.update_cached_miller_len();
2365 }
2366 }
2367 self.sync_to_active_tab();
2368 return;
2369 }
2370
2371 let Some(parent) = self.view_root.parent() else {
2373 return; };
2375
2376 let parent = parent.to_path_buf();
2377 let dir_we_came_from = self.view_root.clone();
2378
2379 self.forward_history.push(current_state);
2381
2382 let parent_in_tree = parent.starts_with(&self.path) || parent == self.path;
2384
2385 if parent_in_tree {
2386 self.tree_state.collapse(&dir_we_came_from);
2388 self.view_root = parent;
2389 self.tree_state.expand(&self.view_root);
2390 } else {
2391 self.navigate_to_parent_beyond_scan_root(&parent, &dir_we_came_from);
2393 return;
2394 }
2395
2396 self.select_child_by_name(&dir_we_came_from);
2398 self.sync_to_active_tab();
2399 }
2400
2401 fn navigate_to_parent_beyond_scan_root(&mut self, parent: &PathBuf, dir_we_came_from: &PathBuf) {
2404 if let Some(tree) = &self.tree {
2406 if self.has_full_scan {
2407 self.scanned_cache.insert(tree.root_path.clone(), tree.clone());
2408 }
2409 }
2410
2411 let Ok(mut parent_tree) = gravityfile_scan::quick_list(parent) else {
2413 return; };
2415
2416 self.merge_cached_scans_into_tree(&mut parent_tree.root, parent);
2418
2419 self.path = parent.clone();
2421 self.tree = Some(parent_tree);
2422 self.view_root = parent.clone();
2423 self.view_history.clear();
2424 self.has_full_scan = false;
2428
2429 self.tree_state = TreeState::new(parent.clone());
2431
2432 self.update_cached_parent();
2434
2435 self.select_child_by_name(dir_we_came_from);
2437 self.sync_to_active_tab();
2438 }
2439
2440 fn merge_cached_scans_into_tree(&self, node: &mut gravityfile_core::FileNode, node_path: &PathBuf) {
2444 for child in &mut node.children {
2445 if child.is_dir() {
2446 let child_path = node_path.join(&*child.name);
2447 if let Some(cached_tree) = self.scanned_cache.get(&child_path) {
2448 *child = cached_tree.root.clone();
2450 }
2451 }
2452 }
2453 }
2454
2455 fn select_child_by_name(&mut self, target_path: &PathBuf) {
2457 let dir_name = target_path
2458 .file_name()
2459 .and_then(|n| n.to_str())
2460 .unwrap_or("");
2461
2462 match self.layout_mode {
2463 LayoutMode::Tree => {
2464 if let Some((node, root_path)) = self.get_view_root_node() {
2466 let items = TreeView::new(node, &root_path, &self.theme, &self.marked, &self.clipboard)
2467 .flatten(&self.tree_state);
2468 if let Some(idx) = items.iter().position(|item| {
2469 item.path.file_name().and_then(|n| n.to_str()) == Some(dir_name)
2470 }) {
2471 self.tree_state.selected = idx;
2472 } else {
2473 self.tree_state.selected = 0;
2474 }
2475 } else {
2476 self.tree_state.selected = 0;
2477 }
2478 self.tree_state.offset = 0;
2479 self.update_cached_tree_len();
2480 }
2481 LayoutMode::Miller => {
2482 if let Some((node, _)) = self.get_view_root_node() {
2484 let idx = node
2485 .children
2486 .iter()
2487 .position(|c| c.name.as_str() == dir_name)
2488 .unwrap_or(0);
2489 self.miller_state.selected = idx;
2490 } else {
2491 self.miller_state.selected = 0;
2492 }
2493 self.miller_state.offset = 0;
2494 self.update_cached_miller_len();
2495 }
2496 }
2497 }
2498
2499 fn find_node_at_path<'a>(
2501 node: &'a gravityfile_core::FileNode,
2502 target_path: &PathBuf,
2503 current_path: &PathBuf,
2504 ) -> Option<&'a gravityfile_core::FileNode> {
2505 if current_path == target_path {
2506 return Some(node);
2507 }
2508 for child in &node.children {
2509 let child_path = current_path.join(&*child.name);
2510 if let Some(found) = Self::find_node_at_path(child, target_path, &child_path) {
2511 return Some(found);
2512 }
2513 }
2514 None
2515 }
2516
2517 fn get_view_root_node(&self) -> Option<(&gravityfile_core::FileNode, PathBuf)> {
2519 let tree = self.tree.as_ref()?;
2520 let node = Self::find_node_at_path(&tree.root, &self.view_root, &tree.root_path)?;
2521 Some((node, self.view_root.clone()))
2522 }
2523
2524 fn get_parent_node(&self) -> Option<&gravityfile_core::FileNode> {
2526 let tree = self.tree.as_ref()?;
2527 let parent_path = self.view_root.parent()?.to_path_buf();
2528
2529 if parent_path.starts_with(&tree.root_path) || parent_path == tree.root_path {
2531 return Self::find_node_at_path(&tree.root, &parent_path, &tree.root_path);
2532 }
2533
2534 if self.view_root == tree.root_path {
2536 if let Some(cached) = &self.cached_parent_tree {
2537 return Some(&cached.root);
2538 }
2539 }
2540
2541 None
2542 }
2543
2544 fn update_cached_parent(&mut self) {
2547 let Some(tree) = &self.tree else { return };
2548
2549 if self.view_root == tree.root_path {
2551 if let Some(parent_path) = self.view_root.parent() {
2552 let parent_path_buf = parent_path.to_path_buf();
2553 if let Ok(mut parent_tree) = gravityfile_scan::quick_list(&parent_path_buf) {
2555 self.merge_cached_scans_into_tree(&mut parent_tree.root, &parent_path_buf);
2558 self.cached_parent_tree = Some(parent_tree);
2559 }
2560 }
2561 } else {
2562 self.cached_parent_tree = None;
2564 }
2565 }
2566
2567 fn get_current_dir_name(&self) -> Option<String> {
2569 self.view_root
2570 .file_name()
2571 .and_then(|n| n.to_str())
2572 .map(|s| s.to_string())
2573 }
2574
2575 fn handle_command_input(&mut self, key: crossterm::event::KeyEvent) {
2577 match self.command_input.handle_key(key) {
2578 CommandKeyResult::Continue => {}
2579 CommandKeyResult::Cancel => {
2580 self.mode = AppMode::Normal;
2581 }
2582 CommandKeyResult::Execute(cmd) => {
2583 self.mode = AppMode::Normal;
2584 self.execute_command(&cmd);
2585 }
2586 }
2587 }
2588
2589 fn execute_command(&mut self, cmd: &str) {
2591 match parse_command(cmd) {
2592 CommandAction::None => {}
2593 CommandAction::Quit => {
2594 self.mode = AppMode::Quit;
2595 }
2596 CommandAction::Refresh => {
2597 self.marked.clear();
2598 self.start_scan();
2599 }
2600 CommandAction::NavigateTo(target) => {
2601 self.cd_to_path(&target);
2602 }
2603 CommandAction::GoToRoot => {
2604 self.go_to_root();
2605 }
2606 CommandAction::NavigateBack => {
2607 self.navigate_back();
2608 }
2609 CommandAction::ShowHelp => {
2610 self.mode = AppMode::Help;
2611 }
2612 CommandAction::SwitchView(view) => {
2613 self.view = view;
2614 }
2615 CommandAction::ClearMarks => {
2616 self.marked.clear();
2617 }
2618 CommandAction::ToggleDetails => {
2619 self.show_details = !self.show_details;
2620 }
2621 CommandAction::SetTheme(theme_cmd) => match theme_cmd {
2622 ThemeCommand::Dark => self.theme = Theme::dark(),
2623 ThemeCommand::Light => self.theme = Theme::light(),
2624 ThemeCommand::Toggle => self.theme = self.theme.toggle(),
2625 },
2626 CommandAction::SetLayout(layout_cmd) => {
2627 match layout_cmd {
2628 LayoutCommand::Tree => {
2629 if self.layout_mode != LayoutMode::Tree {
2630 self.sync_layout_selection();
2632 }
2633 }
2634 LayoutCommand::Miller => {
2635 if self.layout_mode != LayoutMode::Miller {
2636 self.sync_layout_selection();
2638 }
2639 }
2640 LayoutCommand::Toggle => {
2641 self.sync_layout_selection();
2642 }
2643 }
2644 }
2645 CommandAction::SetSort(sort_cmd) => {
2646 match sort_cmd {
2647 SortCommand::SizeDesc => self.sort_mode = SortMode::SizeDescending,
2648 SortCommand::SizeAsc => self.sort_mode = SortMode::SizeAscending,
2649 SortCommand::NameAsc => self.sort_mode = SortMode::NameAscending,
2650 SortCommand::NameDesc => self.sort_mode = SortMode::NameDescending,
2651 SortCommand::DateDesc => self.sort_mode = SortMode::ModifiedDescending,
2652 SortCommand::DateAsc => self.sort_mode = SortMode::ModifiedAscending,
2653 SortCommand::CountDesc => self.sort_mode = SortMode::CountDescending,
2654 SortCommand::CountAsc => self.sort_mode = SortMode::CountAscending,
2655 SortCommand::Cycle => self.sort_mode = self.sort_mode.next(),
2656 SortCommand::Reverse => self.sort_mode = self.sort_mode.reverse(),
2657 }
2658 self.apply_sort();
2659 }
2660 CommandAction::Yank => {
2661 self.yank_selection();
2662 }
2663 CommandAction::Cut => {
2664 self.cut_selection();
2665 }
2666 CommandAction::Paste => {
2667 self.paste_clipboard();
2668 }
2669 CommandAction::Delete => {
2670 if !self.marked.is_empty() {
2671 self.mode = AppMode::ConfirmDelete;
2672 }
2673 }
2674 CommandAction::Rename(name) => {
2675 if let Some(name) = name {
2676 if let Some(path) = self.get_selected_path() {
2678 self.execute_rename(path, name);
2679 }
2680 } else {
2681 self.start_rename();
2683 }
2684 }
2685 CommandAction::CreateFile(name) => {
2686 if let Some(name) = name {
2687 self.execute_create_file(self.view_root.clone(), name);
2689 } else {
2690 self.start_create_file();
2692 }
2693 }
2694 CommandAction::CreateDirectory(name) => {
2695 if let Some(name) = name {
2696 self.execute_create_directory(self.view_root.clone(), name);
2698 } else {
2699 self.start_create_directory();
2701 }
2702 }
2703 CommandAction::Take(name) => {
2704 if let Some(name) = name {
2705 self.execute_take(self.view_root.clone(), name);
2707 } else {
2708 self.start_take();
2710 }
2711 }
2712 CommandAction::Undo => {
2713 self.execute_undo();
2714 }
2715 }
2716 }
2717
2718 fn go_to_root(&mut self) {
2720 self.view_root = self.path.clone();
2721 self.view_history.clear();
2722 self.forward_history.clear();
2723 self.tree_state.selected = 0;
2724 self.tree_state.offset = 0;
2725 self.tree_state.expand(&self.view_root);
2726 self.update_cached_tree_len();
2727 self.sync_to_active_tab();
2728 }
2729
2730 fn cd_to_path(&mut self, target: &str) {
2732 if target == "/" || target == "~" {
2733 self.go_to_root();
2734 return;
2735 }
2736 if target == ".." {
2737 self.navigate_back();
2738 return;
2739 }
2740
2741 let Some(tree) = &self.tree else {
2742 return;
2743 };
2744
2745 let target_path = if target.starts_with('/') {
2746 let relative = target.trim_start_matches('/');
2747 self.path.join(relative)
2748 } else {
2749 self.view_root.join(target)
2750 };
2751
2752 if let Some(node) = Self::find_node_at_path(&tree.root, &target_path, &tree.root_path) {
2753 if node.is_dir() {
2754 let saved_expanded = self.tree_state.expanded.clone();
2755 let saved_selected = self.tree_state.selected;
2756 self.view_history
2757 .push((self.view_root.clone(), saved_selected, saved_expanded));
2758
2759 self.view_root = target_path;
2760 self.tree_state.selected = 0;
2761 self.tree_state.offset = 0;
2762 self.tree_state.expand(&self.view_root);
2763 self.update_cached_tree_len();
2764 self.sync_to_active_tab();
2765 }
2766 }
2767 }
2768
2769 pub fn is_marked(&self, path: &PathBuf) -> bool {
2771 self.marked.contains(path)
2772 }
2773
2774 fn cycle_sort_mode(&mut self) {
2776 self.sort_mode = self.sort_mode.next();
2777 self.apply_sort();
2778 }
2779
2780 fn reverse_sort_mode(&mut self) {
2782 self.sort_mode = self.sort_mode.reverse();
2783 self.apply_sort();
2784 }
2785
2786 fn sync_to_active_tab(&mut self) {
2788 if let Some(tab) = self.tab_manager.active_tab_mut() {
2789 tab.view_root = self.view_root.clone();
2790 tab.label = self.view_root
2791 .file_name()
2792 .map(|n| n.to_string_lossy().to_string())
2793 .unwrap_or_else(|| self.view_root.to_string_lossy().to_string());
2794 }
2795 }
2796
2797 fn sync_from_active_tab(&mut self) {
2799 if let Some(tab) = self.tab_manager.active_tab() {
2800 if tab.view_root != self.view_root {
2802 self.view_root = tab.view_root.clone();
2803 self.tree_state.selected = 0;
2805 self.tree_state.offset = 0;
2806 self.tree_state.expand(&self.view_root);
2807 self.miller_state.reset();
2808 match self.layout_mode {
2809 LayoutMode::Tree => self.update_cached_tree_len(),
2810 LayoutMode::Miller => self.update_cached_miller_len(),
2811 }
2812 }
2813 }
2814 }
2815
2816 fn start_search(&mut self) {
2818 self.update_search_paths();
2820 self.search_state.activate();
2821 self.mode = AppMode::Search;
2822 }
2823
2824 fn update_search_paths(&mut self) {
2826 let mut paths = Vec::new();
2827
2828 if let Some(tree) = &self.tree {
2829 Self::collect_paths_recursive(
2830 &tree.root,
2831 &tree.root_path,
2832 &self.view_root,
2833 &mut paths,
2834 );
2835 }
2836
2837 self.search_state.update_paths(paths);
2838 }
2839
2840 fn collect_paths_recursive(
2842 node: &gravityfile_core::FileNode,
2843 current_path: &PathBuf,
2844 view_root: &PathBuf,
2845 paths: &mut Vec<(PathBuf, String, bool)>,
2846 ) {
2847 if !current_path.starts_with(view_root) && current_path != view_root {
2849 return;
2850 }
2851
2852 let display = if current_path == view_root {
2854 ".".to_string()
2855 } else {
2856 current_path
2857 .strip_prefix(view_root)
2858 .map(|p| p.to_string_lossy().to_string())
2859 .unwrap_or_else(|_| node.name.to_string())
2860 };
2861
2862 paths.push((current_path.clone(), display, node.is_dir()));
2863
2864 for child in &node.children {
2866 let child_path = current_path.join(&*child.name);
2867 Self::collect_paths_recursive(child, &child_path, view_root, paths);
2868 }
2869 }
2870
2871 fn handle_search_input(&mut self, key: crossterm::event::KeyEvent) {
2873 use crossterm::event::{KeyCode, KeyModifiers};
2874
2875 match (key.code, key.modifiers) {
2876 (KeyCode::Esc, _) => {
2878 self.search_state.deactivate();
2879 self.mode = AppMode::Normal;
2880 }
2881 (KeyCode::Enter, _) => {
2883 if let Some(result) = self.search_state.selected_result() {
2884 let target_path = result.path.clone();
2885 self.navigate_to_search_result(&target_path);
2886 }
2887 self.search_state.deactivate();
2888 self.mode = AppMode::Normal;
2889 }
2890 (KeyCode::Up, _) | (KeyCode::Char('k'), KeyModifiers::CONTROL) => {
2892 self.search_state.move_up();
2893 }
2894 (KeyCode::Down, _) | (KeyCode::Char('j'), KeyModifiers::CONTROL) => {
2895 self.search_state.move_down();
2896 }
2897 (KeyCode::Tab, _) => {
2899 self.search_state.cycle_mode();
2900 }
2901 (KeyCode::Left, _) => {
2903 self.search_state.move_cursor_left();
2904 }
2905 (KeyCode::Right, _) => {
2906 self.search_state.move_cursor_right();
2907 }
2908 (KeyCode::Home, _) | (KeyCode::Char('a'), KeyModifiers::CONTROL) => {
2909 self.search_state.move_cursor_start();
2910 }
2911 (KeyCode::End, _) | (KeyCode::Char('e'), KeyModifiers::CONTROL) => {
2912 self.search_state.move_cursor_end();
2913 }
2914 (KeyCode::Backspace, _) => {
2916 self.search_state.delete_char_before();
2917 }
2918 (KeyCode::Delete, _) => {
2919 self.search_state.delete_char_at();
2920 }
2921 (KeyCode::Char('u'), KeyModifiers::CONTROL) => {
2923 self.search_state.clear_query();
2924 }
2925 (KeyCode::Char(c), KeyModifiers::NONE | KeyModifiers::SHIFT) => {
2927 self.search_state.insert_char(c);
2928 }
2929 _ => {}
2930 }
2931 }
2932
2933 fn navigate_to_search_result(&mut self, target: &PathBuf) {
2935 if target.is_dir() {
2938 let saved_expanded = self.tree_state.expanded.clone();
2940 let saved_selected = match self.layout_mode {
2941 LayoutMode::Tree => self.tree_state.selected,
2942 LayoutMode::Miller => self.miller_state.selected,
2943 };
2944 self.view_history.push((self.view_root.clone(), saved_selected, saved_expanded));
2945
2946 self.view_root = target.clone();
2948 self.tree_state.selected = 0;
2949 self.tree_state.offset = 0;
2950 self.tree_state.expand(&self.view_root);
2951 self.miller_state.reset();
2952
2953 match self.layout_mode {
2954 LayoutMode::Tree => self.update_cached_tree_len(),
2955 LayoutMode::Miller => self.update_cached_miller_len(),
2956 }
2957 self.sync_to_active_tab();
2958 } else {
2959 if let Some(parent) = target.parent() {
2961 let saved_expanded = self.tree_state.expanded.clone();
2963 let saved_selected = match self.layout_mode {
2964 LayoutMode::Tree => self.tree_state.selected,
2965 LayoutMode::Miller => self.miller_state.selected,
2966 };
2967 self.view_history.push((self.view_root.clone(), saved_selected, saved_expanded));
2968
2969 self.view_root = parent.to_path_buf();
2971 self.tree_state.expand(&self.view_root);
2972
2973 let file_name = target.file_name().and_then(|n| n.to_str()).map(|s| s.to_string());
2975 let target_clone = target.clone();
2976
2977 match self.layout_mode {
2978 LayoutMode::Tree => {
2979 self.tree_state.selected = 0;
2981 self.update_cached_tree_len();
2982
2983 if let Some((node, _)) = self.get_view_root_node() {
2985 let items = TreeView::new(node, &self.view_root, &self.theme, &self.marked, &self.clipboard)
2986 .flatten(&self.tree_state);
2987 if let Some(pos) = items.iter().position(|i| i.path == target_clone) {
2988 self.tree_state.selected = pos;
2989 }
2990 }
2991 }
2992 LayoutMode::Miller => {
2993 if let (Some(file_name), Some((node, _))) = (&file_name, self.get_view_root_node()) {
2995 let idx = node
2996 .children
2997 .iter()
2998 .position(|c| c.name.as_str() == file_name)
2999 .unwrap_or(0);
3000 self.miller_state.selected = idx;
3001 }
3002 self.update_cached_miller_len();
3003 }
3004 }
3005 self.sync_to_active_tab();
3006 }
3007 }
3008 }
3009
3010 fn handle_settings_input(&mut self, action: KeyAction) {
3012 match action {
3013 KeyAction::Cancel | KeyAction::Quit => {
3014 self.settings_state = None;
3016 self.mode = AppMode::Normal;
3017 }
3018 KeyAction::MoveUp => {
3019 if let Some(ref mut state) = self.settings_state {
3020 state.move_up();
3021 }
3022 }
3023 KeyAction::MoveDown => {
3024 if let Some(ref mut state) = self.settings_state {
3025 state.move_down();
3026 }
3027 }
3028 KeyAction::ToggleMark | KeyAction::DrillDown => {
3029 if let Some(ref mut state) = self.settings_state {
3031 state.toggle_selected();
3032 }
3033 }
3034 KeyAction::Sort => {
3035 if let Some(ref state) = self.settings_state {
3037 if state.dirty {
3038 self.user_settings = state.settings.clone();
3040 if let Err(e) = self.user_settings.save() {
3041 self.error = Some(format!("Failed to save settings: {}", e));
3042 } else {
3043 self.scan_on_startup = self.user_settings.scan_on_startup;
3045 }
3046 }
3047 }
3048 self.settings_state = None;
3049 self.mode = AppMode::Normal;
3050 }
3051 _ => {}
3052 }
3053 }
3054
3055 fn apply_sort(&mut self) {
3057 if let Some(ref mut tree) = self.tree {
3058 Self::sort_node_recursive(&mut tree.root, self.sort_mode);
3059 match self.layout_mode {
3061 LayoutMode::Tree => self.update_cached_tree_len(),
3062 LayoutMode::Miller => self.update_cached_miller_len(),
3063 }
3064 }
3065 }
3066
3067 fn sort_node_recursive(node: &mut gravityfile_core::FileNode, mode: SortMode) {
3069 node.children.sort_by(|a, b| {
3071 match mode {
3072 SortMode::SizeDescending => b.size.cmp(&a.size),
3073 SortMode::SizeAscending => a.size.cmp(&b.size),
3074 SortMode::NameAscending => a.name.cmp(&b.name),
3075 SortMode::NameDescending => b.name.cmp(&a.name),
3076 SortMode::ModifiedDescending => b.timestamps.modified.cmp(&a.timestamps.modified),
3077 SortMode::ModifiedAscending => a.timestamps.modified.cmp(&b.timestamps.modified),
3078 SortMode::CountDescending => {
3079 let a_count = a.children.len();
3080 let b_count = b.children.len();
3081 b_count.cmp(&a_count)
3082 }
3083 SortMode::CountAscending => {
3084 let a_count = a.children.len();
3085 let b_count = b.children.len();
3086 a_count.cmp(&b_count)
3087 }
3088 }
3089 });
3090
3091 for child in &mut node.children {
3093 Self::sort_node_recursive(child, mode);
3094 }
3095 }
3096
3097 fn get_filtered_duplicates(
3099 &self,
3100 ) -> Option<(Vec<&gravityfile_analyze::DuplicateGroup>, u64)> {
3101 let dups = self.duplicates.as_ref()?;
3102
3103 if self.view_root == self.path {
3104 let total: u64 = dups.groups.iter().map(|g| g.wasted_bytes).sum();
3105 return Some((dups.groups.iter().collect(), total));
3106 }
3107
3108 let filtered: Vec<&gravityfile_analyze::DuplicateGroup> = dups
3109 .groups
3110 .iter()
3111 .filter(|g| g.paths.iter().any(|p| p.starts_with(&self.view_root)))
3112 .collect();
3113
3114 let total_wasted: u64 = filtered
3115 .iter()
3116 .map(|g| {
3117 let paths_in_view = g
3118 .paths
3119 .iter()
3120 .filter(|p| p.starts_with(&self.view_root))
3121 .count();
3122 if paths_in_view > 1 {
3123 g.size * (paths_in_view as u64 - 1)
3124 } else {
3125 0
3126 }
3127 })
3128 .sum();
3129
3130 Some((filtered, total_wasted))
3131 }
3132
3133 fn get_filtered_stale_dirs(&self) -> Option<Vec<&gravityfile_analyze::StaleDirectory>> {
3135 let age = self.age_report.as_ref()?;
3136
3137 if self.view_root == self.path {
3138 return Some(age.stale_directories.iter().collect());
3139 }
3140
3141 let filtered: Vec<&gravityfile_analyze::StaleDirectory> = age
3142 .stale_directories
3143 .iter()
3144 .filter(|d| d.path.starts_with(&self.view_root))
3145 .collect();
3146
3147 Some(filtered)
3148 }
3149}
3150
3151impl Widget for &App {
3152 fn render(self, area: Rect, buf: &mut Buffer) {
3153 let ctx = RenderContext {
3154 mode: self.mode,
3155 view: self.view,
3156 theme: &self.theme,
3157 path: &self.path,
3158 view_root: &self.view_root,
3159 show_details: self.show_details,
3160 tree: self.tree.as_ref(),
3161 tree_state: &self.tree_state,
3162 layout_mode: self.layout_mode,
3163 miller_state: &self.miller_state,
3164 scan_progress: self.scan_progress.as_ref(),
3165 deletion_progress: self.deletion_progress.as_ref(),
3166 duplicates: self.duplicates.as_ref(),
3167 age_report: self.age_report.as_ref(),
3168 warnings: &self.warnings,
3169 duplicates_state: &self.duplicates_state,
3170 selected_stale_dir: self.selected_stale_dir,
3171 selected_warning: self.selected_warning,
3172 marked: &self.marked,
3173 deletion_message: self.deletion_message.as_ref(),
3174 operation_message: self.operation_message.as_ref(),
3175 error: self.error.as_deref(),
3176 command_input: self.command_input.buffer(),
3177 command_cursor: self.command_input.cursor(),
3178 input_state: self.input_state.as_ref(),
3179 operation_progress: self.operation_progress.as_ref(),
3180 pending_conflict: self.pending_conflict.as_ref(),
3181 clipboard: &self.clipboard,
3182 get_path_size: Box::new(|p| self.get_path_size(p)),
3183 get_selected_info: self.get_selected_info(),
3184 get_view_root_node: self.get_view_root_node(),
3185 get_parent_node: self.get_parent_node(),
3186 current_dir_name: self.get_current_dir_name(),
3187 get_filtered_duplicates: self.get_filtered_duplicates(),
3188 get_filtered_stale_dirs: self.get_filtered_stale_dirs(),
3189 sort_mode: self.sort_mode,
3190 search_state: &self.search_state,
3191 tab_manager: &self.tab_manager,
3192 preview_content: &self.preview_state.content,
3193 preview_mode: self.preview_state.mode,
3194 has_full_scan: self.has_full_scan,
3195 settings_state: self.settings_state.as_ref(),
3196 };
3197
3198 render_app(&ctx, area, buf);
3199 }
3200}