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 self.view == View::Errors {
937 if let Some(warning) = self.warnings.get(self.selected_warning) {
939 if warning.kind == gravityfile_core::WarningKind::BrokenSymlink {
941 self.marked.insert(warning.path.clone());
942 }
943 }
944 } else if let Some(path) = self.get_selected_path() {
945 self.marked.insert(path);
946 }
947 }
948 if !self.marked.is_empty() {
949 self.mode = AppMode::ConfirmDelete;
950 }
951 }
952 KeyAction::Rename => {
953 self.start_rename();
954 }
955 KeyAction::CreateFile => {
956 self.start_create_file();
957 }
958 KeyAction::CreateDirectory => {
959 self.start_create_directory();
960 }
961 KeyAction::Take => {
962 self.start_take();
963 }
964 KeyAction::GoTo => {
965 self.start_goto();
966 }
967 KeyAction::Undo => {
968 self.execute_undo();
969 }
970
971 KeyAction::Confirm => {
972 if !self.marked.is_empty() {
973 self.mode = AppMode::ConfirmDelete;
974 } else if self.view == View::Explorer {
975 self.toggle_selected();
976 }
977 }
978
979 KeyAction::DrillDown | KeyAction::OpenFile => {
980 if self.view == View::Explorer {
981 let is_file = self.is_selected_file();
983 if is_file {
984 self.open_selected_file();
985 } else {
986 match self.layout_mode {
987 LayoutMode::Tree => self.drill_into_selected(),
988 LayoutMode::Miller => self.drill_into_miller_selected(),
989 }
990 }
991 }
992 }
993 KeyAction::NavigateBack => {
994 if self.view == View::Explorer {
995 self.navigate_back();
996 }
997 }
998
999 KeyAction::CommandMode => {
1000 self.command_input.clear();
1001 self.mode = AppMode::Command;
1002 }
1003
1004 KeyAction::ToggleDetails => {
1005 self.show_details = !self.show_details;
1006 }
1007 KeyAction::ToggleHelp => {
1008 self.mode = AppMode::Help;
1009 }
1010 KeyAction::ToggleTheme => {
1011 self.theme = self.theme.toggle();
1012 }
1013 KeyAction::ToggleLayout => {
1014 self.sync_layout_selection();
1015 }
1016 KeyAction::CyclePreviewMode => {
1017 self.preview_state.cycle_mode();
1018 }
1019 KeyAction::OpenSettings => {
1020 self.settings_state = Some(SettingsState::new(self.user_settings.clone()));
1021 self.mode = AppMode::Settings;
1022 }
1023 KeyAction::Refresh => {
1024 self.marked.clear();
1025 self.start_scan();
1026 }
1027 KeyAction::Sort => {
1028 self.cycle_sort_mode();
1029 }
1030 KeyAction::ReverseSort => {
1031 self.reverse_sort_mode();
1032 }
1033 KeyAction::Search => {
1034 self.start_search();
1035 }
1036
1037 _ => {}
1038 }
1039 }
1040
1041 fn toggle_mark(&mut self) {
1043 let path = match self.view {
1044 View::Explorer => {
1045 if let Some((node, root_path)) = self.get_view_root_node() {
1046 match self.layout_mode {
1047 LayoutMode::Tree => {
1048 let items =
1049 TreeView::new(node, &root_path, &self.theme, &self.marked, &self.clipboard).flatten(&self.tree_state);
1050 items.get(self.tree_state.selected).map(|i| i.path.clone())
1051 }
1052 LayoutMode::Miller => {
1053 node.children.get(self.miller_state.selected).map(|child| {
1054 self.view_root.join(&*child.name)
1055 })
1056 }
1057 }
1058 } else {
1059 None
1060 }
1061 }
1062 View::Duplicates => {
1063 let (is_on_header, group_paths, selected_file) = if let Some((filtered, _)) = self.get_filtered_duplicates() {
1065 let is_on_header = !self.duplicates_state.is_expanded(self.duplicates_state.selected_group)
1066 || self.duplicates_state.selected_item(self.duplicates_state.selected_group) == 0;
1067
1068 let group_paths: Vec<PathBuf> = filtered
1069 .get(self.duplicates_state.selected_group)
1070 .map(|g| g.paths.clone())
1071 .unwrap_or_default();
1072
1073 let selected_file = if !is_on_header {
1074 self.duplicates_state.selected_file_path(&filtered).cloned()
1075 } else {
1076 None
1077 };
1078
1079 (is_on_header, group_paths, selected_file)
1080 } else {
1081 return;
1082 };
1083
1084 if is_on_header {
1085 let any_marked = group_paths.iter().any(|p| self.marked.contains(p));
1087
1088 if any_marked {
1089 for path in &group_paths {
1091 self.marked.remove(path);
1092 }
1093 } else {
1094 for path in group_paths {
1096 if path != self.path {
1097 self.marked.insert(path);
1098 }
1099 }
1100 }
1101 return; } else {
1103 selected_file
1105 }
1106 }
1107 View::Age => {
1108 if let Some(filtered) = self.get_filtered_stale_dirs() {
1109 let selected = self.selected_stale_dir.min(filtered.len().saturating_sub(1));
1110 filtered.get(selected).map(|d| d.path.clone())
1111 } else {
1112 None
1113 }
1114 }
1115 View::Errors => {
1116 if let Some(warning) = self.warnings.get(self.selected_warning) {
1118 if warning.kind == gravityfile_core::WarningKind::BrokenSymlink {
1119 Some(warning.path.clone())
1120 } else {
1121 None
1122 }
1123 } else {
1124 None
1125 }
1126 }
1127 };
1128
1129 if let Some(path) = path {
1130 if path != self.path {
1131 if self.marked.contains(&path) {
1132 self.marked.remove(&path);
1133 } else {
1134 self.marked.insert(path);
1135 }
1136 }
1137 }
1138 }
1139
1140 fn execute_deletion(&mut self) {
1142 let paths_with_sizes: Vec<(PathBuf, u64)> = self
1143 .marked
1144 .iter()
1145 .map(|p| (p.clone(), self.get_path_size(p).unwrap_or(0)))
1146 .collect();
1147
1148 let total = paths_with_sizes.len();
1149 if total == 0 {
1150 return;
1151 }
1152
1153 self.marked.clear();
1154 self.mode = AppMode::Deleting;
1155 self.deletion_progress = Some(DeletionProgress {
1156 total,
1157 deleted: 0,
1158 failed: 0,
1159 bytes_freed: 0,
1160 current: paths_with_sizes.first().map(|(p, _)| p.clone()),
1161 });
1162
1163 self.scan_rx = Some(deletion::start_deletion(paths_with_sizes));
1164 }
1165
1166 fn get_path_size(&self, target: &PathBuf) -> Option<u64> {
1168 let tree = self.tree.as_ref()?;
1169
1170 fn find_size(
1171 node: &gravityfile_core::FileNode,
1172 target: &PathBuf,
1173 current: &PathBuf,
1174 ) -> Option<u64> {
1175 if current == target {
1176 return Some(node.size);
1177 }
1178 for child in &node.children {
1179 let child_path = current.join(&*child.name);
1180 if let Some(size) = find_size(child, target, &child_path) {
1181 return Some(size);
1182 }
1183 }
1184 None
1185 }
1186
1187 find_size(&tree.root, target, &tree.root_path)
1188 }
1189
1190 fn yank_selection(&mut self) {
1192 let paths: Vec<PathBuf> = if self.marked.is_empty() {
1193 self.get_selected_path().into_iter().collect()
1194 } else {
1195 self.marked.iter().cloned().collect()
1196 };
1197
1198 if !paths.is_empty() {
1199 self.clipboard.yank(paths, self.view_root.clone());
1200 self.marked.clear();
1201 }
1202 }
1203
1204 fn cut_selection(&mut self) {
1206 let paths: Vec<PathBuf> = if self.marked.is_empty() {
1207 self.get_selected_path().into_iter().collect()
1208 } else {
1209 self.marked.iter().cloned().collect()
1210 };
1211
1212 if !paths.is_empty() {
1213 self.clipboard.cut(paths, self.view_root.clone());
1214 self.marked.clear();
1215 }
1216 }
1217
1218 fn paste_clipboard(&mut self) {
1222 self.paste_with_resolution(None);
1223 }
1224
1225 fn paste_with_resolution(&mut self, resolution: Option<gravityfile_ops::ConflictResolution>) {
1227 if self.clipboard.is_empty() {
1228 return;
1229 }
1230
1231 let destination = self.get_paste_destination();
1233
1234 let sources = self.clipboard.paths.clone();
1235 let mode = self.clipboard.mode;
1236
1237 if resolution.is_none() {
1239 if let Some(conflict) = self.check_paste_conflict(&sources, &destination) {
1240 self.pending_operation = Some(PendingOperation::Paste {
1242 sources: sources.clone(),
1243 destination: destination.clone(),
1244 mode,
1245 });
1246 self.pending_conflict = Some(conflict);
1247 self.mode = AppMode::ConflictResolution;
1248 return;
1249 }
1250 }
1251
1252 match mode {
1253 ClipboardMode::Copy => {
1254 self.mode = AppMode::Copying;
1255 let options = CopyOptions {
1256 conflict_resolution: resolution,
1257 preserve_timestamps: false,
1258 };
1259 let rx = gravityfile_ops::start_copy(sources, destination, options);
1260 self.scan_rx = Some(Self::adapt_copy_rx(rx));
1261 }
1262 ClipboardMode::Cut => {
1263 self.mode = AppMode::Moving;
1264 let options = MoveOptions {
1265 conflict_resolution: resolution,
1266 };
1267 let rx = gravityfile_ops::start_move(sources, destination, options);
1268 self.scan_rx = Some(Self::adapt_move_rx(rx));
1269 self.clipboard.clear();
1270 }
1271 ClipboardMode::Empty => {}
1272 }
1273 }
1274
1275 fn check_paste_conflict(&self, sources: &[PathBuf], destination: &PathBuf) -> Option<Conflict> {
1277 use gravityfile_ops::{ConflictKind};
1278
1279 for source in sources {
1280 if let Some(filename) = source.file_name() {
1281 let dest_path = destination.join(filename);
1282
1283 if dest_path.exists() {
1285 let kind = if source == &dest_path {
1287 ConflictKind::SameFile
1289 } else if dest_path.is_dir() {
1290 ConflictKind::DirectoryExists
1291 } else {
1292 ConflictKind::FileExists
1293 };
1294
1295 return Some(Conflict::new(
1296 source.clone(),
1297 dest_path,
1298 kind,
1299 ));
1300 }
1301 }
1302 }
1303 None
1304 }
1305
1306 fn get_paste_destination(&self) -> PathBuf {
1311 if let Some(selected_path) = self.get_selected_path() {
1313 if selected_path.is_dir() {
1315 return selected_path;
1316 } else {
1317 if let Some(parent) = selected_path.parent() {
1319 return parent.to_path_buf();
1320 }
1321 }
1322 }
1323 self.view_root.clone()
1325 }
1326
1327 fn adapt_copy_rx(mut rx: mpsc::Receiver<CopyResult>) -> mpsc::Receiver<ScanResult> {
1329 let (tx, result_rx) = mpsc::channel(100);
1330 tokio::spawn(async move {
1331 while let Some(result) = rx.recv().await {
1332 let scan_result = match result {
1333 CopyResult::Progress(p) => ScanResult::OperationProgress(p),
1334 CopyResult::Conflict(c) => ScanResult::OperationConflict(c),
1335 CopyResult::Complete(c) => ScanResult::OperationComplete {
1336 operation_type: c.operation_type,
1337 succeeded: c.succeeded,
1338 failed: c.failed,
1339 bytes_processed: c.bytes_processed,
1340 },
1341 };
1342 if tx.send(scan_result).await.is_err() {
1343 break;
1344 }
1345 }
1346 });
1347 result_rx
1348 }
1349
1350 fn adapt_move_rx(mut rx: mpsc::Receiver<MoveResult>) -> mpsc::Receiver<ScanResult> {
1352 let (tx, result_rx) = mpsc::channel(100);
1353 tokio::spawn(async move {
1354 while let Some(result) = rx.recv().await {
1355 let scan_result = match result {
1356 MoveResult::Progress(p) => ScanResult::OperationProgress(p),
1357 MoveResult::Conflict(c) => ScanResult::OperationConflict(c),
1358 MoveResult::Complete(c) => ScanResult::OperationComplete {
1359 operation_type: c.operation_type,
1360 succeeded: c.succeeded,
1361 failed: c.failed,
1362 bytes_processed: c.bytes_processed,
1363 },
1364 };
1365 if tx.send(scan_result).await.is_err() {
1366 break;
1367 }
1368 }
1369 });
1370 result_rx
1371 }
1372
1373 fn handle_conflict_key(&mut self, event: crossterm::event::KeyEvent) {
1375 use crossterm::event::KeyCode;
1376 use gravityfile_ops::ConflictResolution;
1377
1378 let is_same_file = self.pending_conflict
1380 .as_ref()
1381 .map(|c| matches!(c.kind, gravityfile_ops::ConflictKind::SameFile))
1382 .unwrap_or(false);
1383
1384 let resolution = match event.code {
1385 KeyCode::Char('s') => Some(ConflictResolution::Skip),
1387 KeyCode::Char('S') if !is_same_file => Some(ConflictResolution::SkipAll),
1389 KeyCode::Char('o') if !is_same_file => Some(ConflictResolution::Overwrite),
1391 KeyCode::Char('O') if !is_same_file => Some(ConflictResolution::OverwriteAll),
1393 KeyCode::Char('r') => Some(ConflictResolution::AutoRename),
1395 KeyCode::Esc => Some(ConflictResolution::Abort),
1397 KeyCode::Enter if is_same_file => Some(ConflictResolution::Skip),
1399 KeyCode::Enter => {
1401 if let Some(conflict) = &self.pending_conflict {
1402 if matches!(conflict.kind, gravityfile_ops::ConflictKind::SourceIsAncestor) {
1403 Some(ConflictResolution::Abort)
1404 } else {
1405 None
1406 }
1407 } else {
1408 None
1409 }
1410 }
1411 _ => None,
1412 };
1413
1414 if let Some(res) = resolution {
1415 self.pending_conflict = None;
1417
1418 let pending = self.pending_operation.take();
1420
1421 if matches!(res, ConflictResolution::Abort | ConflictResolution::Skip | ConflictResolution::SkipAll) {
1423 self.mode = AppMode::Normal;
1424 self.scan_rx = None;
1425 self.operation_progress = None;
1426 return;
1429 }
1430
1431 if let Some(pending_op) = pending {
1433 match pending_op {
1434 PendingOperation::Paste { sources, destination: _, mode } => {
1435 let old_clipboard = self.clipboard.clone();
1437 self.clipboard.paths = sources;
1438 self.clipboard.mode = mode;
1439
1440 self.paste_with_resolution(Some(res));
1442
1443 if mode == ClipboardMode::Copy {
1446 self.clipboard = old_clipboard;
1447 }
1448 }
1449 }
1450 } else {
1451 self.mode = AppMode::Normal;
1453 self.scan_rx = None;
1454 self.operation_progress = None;
1455 }
1456 }
1457 }
1458
1459 fn start_rename(&mut self) {
1461 if let Some(path) = self.get_selected_path() {
1462 let name = path
1463 .file_name()
1464 .map(|n| n.to_string_lossy().to_string())
1465 .unwrap_or_default();
1466 let mut input = InputState::with_initial(&name);
1467 input.context_path = Some(path);
1468 self.input_state = Some(input);
1469 self.mode = AppMode::Renaming;
1470 }
1471 }
1472
1473 fn start_create_file(&mut self) {
1475 let mut input = InputState::new();
1476 input.context_path = Some(self.view_root.clone());
1477 self.input_state = Some(input);
1478 self.mode = AppMode::CreatingFile;
1479 }
1480
1481 fn start_create_directory(&mut self) {
1483 let mut input = InputState::new();
1484 input.context_path = Some(self.view_root.clone());
1485 self.input_state = Some(input);
1486 self.mode = AppMode::CreatingDirectory;
1487 }
1488
1489 fn start_take(&mut self) {
1491 let mut input = InputState::new();
1492 input.context_path = Some(self.view_root.clone());
1493 self.input_state = Some(input);
1494 self.mode = AppMode::Taking;
1495 }
1496
1497 fn start_goto(&mut self) {
1499 let current_path = self.view_root.to_string_lossy().to_string();
1501 let mut input = InputState::with_initial(¤t_path);
1502 input.context_path = Some(self.view_root.clone());
1503 self.input_state = Some(input);
1504 self.mode = AppMode::GoingTo;
1505 }
1506
1507 fn execute_goto(&mut self, path_str: &str) {
1509 let path = if path_str.starts_with('~') {
1510 if let Some(home) = dirs::home_dir() {
1512 home.join(&path_str[1..].trim_start_matches('/'))
1513 } else {
1514 std::path::PathBuf::from(path_str)
1515 }
1516 } else if path_str.starts_with('/') {
1517 std::path::PathBuf::from(path_str)
1518 } else {
1519 self.view_root.join(path_str)
1521 };
1522
1523 let path = path.canonicalize().unwrap_or(path);
1525
1526 if path.is_dir() {
1527 let within_scanned_tree = if let Some(tree) = &self.tree {
1529 path.starts_with(&tree.root_path) || tree.root_path.starts_with(&path)
1530 } else {
1531 false
1532 };
1533
1534 if within_scanned_tree {
1535 self.view_root = path.clone();
1537 self.view_history.clear();
1538 self.forward_history.clear();
1539 self.tree_state.selected = 0;
1540 self.tree_state.offset = 0;
1541 self.tree_state.expand(&self.view_root);
1542 self.miller_state.reset();
1543 self.update_cached_tree_len();
1544 self.update_cached_miller_len();
1545 self.preview_state.content = crate::preview::PreviewContent::Empty;
1546
1547 if let Some(tab) = self.tab_manager.active_tab_mut() {
1549 tab.view_root = self.view_root.clone();
1550 }
1551 } else {
1552 self.path = path.clone();
1555 self.view_root = path.clone();
1556 self.view_history.clear();
1557 self.forward_history.clear();
1558 self.tree_state = TreeState::new(self.path.clone());
1559 self.miller_state.reset();
1560 self.marked.clear();
1561 self.clipboard.clear();
1562 self.duplicates = None;
1563 self.age_report = None;
1564 self.warnings.clear();
1565 self.preview_state.content = crate::preview::PreviewContent::Empty;
1566 self.scan_progress = None;
1567
1568 self.tree = gravityfile_scan::quick_list(&path).ok();
1570 self.update_cached_tree_len();
1571 self.update_cached_miller_len();
1572
1573 if let Some(tab) = self.tab_manager.active_tab_mut() {
1575 tab.path = self.path.clone();
1576 tab.view_root = self.view_root.clone();
1577 tab.label = self.path
1578 .file_name()
1579 .map(|n| n.to_string_lossy().to_string())
1580 .unwrap_or_else(|| self.path.to_string_lossy().to_string());
1581 tab.history.clear();
1582 }
1583
1584 }
1586 } else {
1587 self.error = Some(format!("Not a directory: {}", path.display()));
1588 }
1589 }
1590
1591 fn execute_undo(&mut self) {
1593 self.operation_message = Some((false, "Undo not yet implemented".to_string()));
1596 }
1597
1598 fn handle_input_event(&mut self, key: crossterm::event::KeyEvent) {
1600 let current_mode = self.mode;
1601
1602 if let Some(ref mut input) = self.input_state {
1603 match input.handle_key(key) {
1604 InputResult::Continue => {}
1605 InputResult::Cancel => {
1606 self.input_state = None;
1607 self.mode = AppMode::Normal;
1608 }
1609 InputResult::Submit(value) => {
1610 let context_path = input.context_path.clone();
1611 self.input_state = None;
1612
1613 match current_mode {
1614 AppMode::Renaming => {
1615 if let Some(source) = context_path {
1616 self.execute_rename(source, value);
1617 }
1618 }
1619 AppMode::CreatingFile => {
1620 if let Some(parent) = context_path {
1621 self.execute_create_file(parent, value);
1622 }
1623 }
1624 AppMode::CreatingDirectory => {
1625 if let Some(parent) = context_path {
1626 self.execute_create_directory(parent, value);
1627 }
1628 }
1629 AppMode::Taking => {
1630 if let Some(parent) = context_path {
1631 self.execute_take(parent, value);
1632 }
1633 }
1634 AppMode::GoingTo => {
1635 self.execute_goto(&value);
1636 }
1637 _ => {}
1638 }
1639
1640 self.mode = AppMode::Normal;
1641 }
1642 }
1643 } else {
1644 self.mode = AppMode::Normal;
1646 }
1647 }
1648
1649 fn execute_rename(&mut self, source: std::path::PathBuf, new_name: String) {
1651 use gravityfile_ops::start_rename;
1652
1653 if new_name.is_empty() || new_name.contains('/') || new_name.contains('\\') {
1655 self.operation_message = Some((false, "Invalid file name".to_string()));
1656 return;
1657 }
1658
1659 let rx = start_rename(source.clone(), new_name.clone());
1660
1661 let adapted_rx = Self::adapt_rename_rx(rx);
1663 self.scan_rx = Some(adapted_rx);
1664 self.mode = AppMode::Normal;
1665
1666 self.operation_message = Some((true, format!("Renaming to {}...", new_name)));
1668 }
1669
1670 fn adapt_rename_rx(
1672 mut rx: mpsc::Receiver<gravityfile_ops::RenameResult>,
1673 ) -> mpsc::Receiver<ScanResult> {
1674 let (tx, adapted_rx) = mpsc::channel(100);
1675
1676 tokio::spawn(async move {
1677 while let Some(result) = rx.recv().await {
1678 let scan_result = match result {
1679 gravityfile_ops::RenameResult::Progress(progress) => {
1680 ScanResult::OperationProgress(progress)
1681 }
1682 gravityfile_ops::RenameResult::Complete(complete) => {
1683 ScanResult::OperationComplete {
1684 operation_type: complete.operation_type,
1685 succeeded: complete.succeeded,
1686 failed: complete.failed,
1687 bytes_processed: complete.bytes_processed,
1688 }
1689 }
1690 };
1691 if tx.send(scan_result).await.is_err() {
1692 break;
1693 }
1694 }
1695 });
1696
1697 adapted_rx
1698 }
1699
1700 fn execute_create_file(&mut self, parent: std::path::PathBuf, name: String) {
1702 use gravityfile_ops::start_create_file;
1703
1704 if name.is_empty() || name.contains('/') || name.contains('\\') {
1706 self.operation_message = Some((false, "Invalid file name".to_string()));
1707 return;
1708 }
1709
1710 let path = parent.join(&name);
1711 let rx = start_create_file(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 file {}...", name)));
1717 }
1718
1719 fn execute_create_directory(&mut self, parent: std::path::PathBuf, name: String) {
1721 use gravityfile_ops::start_create_directory;
1722
1723 if name.is_empty() || name.contains('/') || name.contains('\\') {
1725 self.operation_message = Some((false, "Invalid directory name".to_string()));
1726 return;
1727 }
1728
1729 let path = parent.join(&name);
1730 let rx = start_create_directory(path.clone());
1731
1732 let adapted_rx = Self::adapt_create_rx(rx);
1733 self.scan_rx = Some(adapted_rx);
1734
1735 self.operation_message = Some((true, format!("Creating directory {}...", name)));
1736 }
1737
1738 fn execute_take(&mut self, parent: std::path::PathBuf, name: String) {
1740 if name.is_empty() || name.contains('/') || name.contains('\\') {
1742 self.operation_message = Some((false, "Invalid directory name".to_string()));
1743 return;
1744 }
1745
1746 let path = parent.join(&name);
1747
1748 if path.exists() {
1750 if path.is_dir() {
1751 self.operation_message = Some((true, format!("Directory {} already exists, navigating...", name)));
1753 } else {
1754 self.operation_message = Some((false, format!("A file named {} already exists", name)));
1756 return;
1757 }
1758 } else {
1759 if let Err(e) = std::fs::create_dir(&path) {
1761 self.operation_message = Some((false, format!("Failed to create directory: {}", e)));
1762 return;
1763 }
1764 self.operation_message = Some((true, format!("Created and entered {}", name)));
1765 }
1766
1767 let saved_expanded = self.tree_state.expanded.clone();
1770 let saved_selected = match self.layout_mode {
1771 LayoutMode::Tree => self.tree_state.selected,
1772 LayoutMode::Miller => self.miller_state.selected,
1773 };
1774 self.view_history.push((self.view_root.clone(), saved_selected, saved_expanded));
1775
1776 self.view_root = path;
1778
1779 match self.layout_mode {
1781 LayoutMode::Tree => {
1782 self.tree_state.selected = 0;
1783 self.tree_state.offset = 0;
1784 self.tree_state.expand(&self.view_root);
1785 }
1786 LayoutMode::Miller => {
1787 self.miller_state.reset();
1788 }
1789 }
1790
1791 self.sync_to_active_tab();
1792
1793 self.start_scan();
1795 }
1796
1797 fn adapt_create_rx(
1799 mut rx: mpsc::Receiver<gravityfile_ops::CreateResult>,
1800 ) -> mpsc::Receiver<ScanResult> {
1801 let (tx, adapted_rx) = mpsc::channel(100);
1802
1803 tokio::spawn(async move {
1804 while let Some(result) = rx.recv().await {
1805 let scan_result = match result {
1806 gravityfile_ops::CreateResult::Progress(progress) => {
1807 ScanResult::OperationProgress(progress)
1808 }
1809 gravityfile_ops::CreateResult::Complete(complete) => {
1810 ScanResult::OperationComplete {
1811 operation_type: complete.operation_type,
1812 succeeded: complete.succeeded,
1813 failed: complete.failed,
1814 bytes_processed: complete.bytes_processed,
1815 }
1816 }
1817 };
1818 if tx.send(scan_result).await.is_err() {
1819 break;
1820 }
1821 }
1822 });
1823
1824 adapted_rx
1825 }
1826
1827 fn move_up(&mut self) {
1829 match self.view {
1830 View::Explorer => match self.layout_mode {
1831 LayoutMode::Tree => self.tree_state.move_up(1),
1832 LayoutMode::Miller => {
1833 self.miller_state.move_up(1);
1834 self.update_preview();
1835 }
1836 },
1837 View::Duplicates => {
1838 let (group_count, file_counts) = if let Some((filtered, _)) = self.get_filtered_duplicates() {
1840 let counts: Vec<usize> = filtered.iter().map(|g| g.paths.len()).collect();
1841 (filtered.len(), counts)
1842 } else {
1843 (0, Vec::new())
1844 };
1845 if group_count > 0 {
1846 self.duplicates_state.move_up(group_count, |idx| {
1847 file_counts.get(idx).copied().unwrap_or(0)
1848 });
1849 }
1850 }
1851 View::Age => {
1852 self.selected_stale_dir = self.selected_stale_dir.saturating_sub(1);
1853 }
1854 View::Errors => {
1855 self.selected_warning = self.selected_warning.saturating_sub(1);
1856 }
1857 }
1858 }
1859
1860 fn move_down(&mut self) {
1861 match self.view {
1862 View::Explorer => match self.layout_mode {
1863 LayoutMode::Tree => {
1864 self.tree_state.move_down(1, self.cached_tree_len);
1865 }
1866 LayoutMode::Miller => {
1867 self.miller_state.move_down(1, self.cached_miller_len);
1868 self.update_preview();
1869 }
1870 },
1871 View::Duplicates => {
1872 let (group_count, file_counts) = if let Some((filtered, _)) = self.get_filtered_duplicates() {
1874 let counts: Vec<usize> = filtered.iter().map(|g| g.paths.len()).collect();
1875 (filtered.len(), counts)
1876 } else {
1877 (0, Vec::new())
1878 };
1879 if group_count > 0 {
1880 self.duplicates_state.move_down(group_count, |idx| {
1881 file_counts.get(idx).copied().unwrap_or(0)
1882 });
1883 }
1884 }
1885 View::Age => {
1886 if let Some(filtered) = self.get_filtered_stale_dirs() {
1887 let max = filtered.len().saturating_sub(1);
1888 self.selected_stale_dir = (self.selected_stale_dir + 1).min(max);
1889 }
1890 }
1891 View::Errors => {
1892 let max = self.warnings.len().saturating_sub(1);
1893 self.selected_warning = (self.selected_warning + 1).min(max);
1894 }
1895 }
1896 }
1897
1898 fn page_up(&mut self) {
1899 match self.view {
1900 View::Explorer => match self.layout_mode {
1901 LayoutMode::Tree => self.tree_state.move_up(PAGE_SIZE),
1902 LayoutMode::Miller => {
1903 self.miller_state.move_up(PAGE_SIZE);
1904 self.update_preview();
1905 }
1906 },
1907 View::Duplicates => {
1908 self.duplicates_state.selected_group =
1910 self.duplicates_state.selected_group.saturating_sub(PAGE_SIZE);
1911 }
1912 View::Age => {
1913 self.selected_stale_dir = self.selected_stale_dir.saturating_sub(PAGE_SIZE);
1914 }
1915 View::Errors => {
1916 self.selected_warning = self.selected_warning.saturating_sub(PAGE_SIZE);
1917 }
1918 }
1919 }
1920
1921 fn page_down(&mut self) {
1922 match self.view {
1923 View::Explorer => match self.layout_mode {
1924 LayoutMode::Tree => {
1925 self.tree_state.move_down(PAGE_SIZE, self.cached_tree_len);
1926 }
1927 LayoutMode::Miller => {
1928 self.miller_state.move_down(PAGE_SIZE, self.cached_miller_len);
1929 self.update_preview();
1930 }
1931 },
1932 View::Duplicates => {
1933 if let Some((filtered, _)) = self.get_filtered_duplicates() {
1935 let max = filtered.len().saturating_sub(1);
1936 self.duplicates_state.selected_group =
1937 (self.duplicates_state.selected_group + PAGE_SIZE).min(max);
1938 }
1939 }
1940 View::Age => {
1941 if let Some(filtered) = self.get_filtered_stale_dirs() {
1942 let max = filtered.len().saturating_sub(1);
1943 self.selected_stale_dir = (self.selected_stale_dir + PAGE_SIZE).min(max);
1944 }
1945 }
1946 View::Errors => {
1947 let max = self.warnings.len().saturating_sub(1);
1948 self.selected_warning = (self.selected_warning + PAGE_SIZE).min(max);
1949 }
1950 }
1951 }
1952
1953 fn jump_to_top(&mut self) {
1954 match self.view {
1955 View::Explorer => match self.layout_mode {
1956 LayoutMode::Tree => self.tree_state.jump_to_top(),
1957 LayoutMode::Miller => {
1958 self.miller_state.jump_to_top();
1959 self.update_preview();
1960 }
1961 },
1962 View::Duplicates => self.duplicates_state.reset(),
1963 View::Age => self.selected_stale_dir = 0,
1964 View::Errors => self.selected_warning = 0,
1965 }
1966 }
1967
1968 fn jump_to_bottom(&mut self) {
1969 match self.view {
1970 View::Explorer => match self.layout_mode {
1971 LayoutMode::Tree => {
1972 self.tree_state.jump_to_bottom(self.cached_tree_len);
1973 }
1974 LayoutMode::Miller => {
1975 self.miller_state.jump_to_bottom(self.cached_miller_len);
1976 self.update_preview();
1977 }
1978 },
1979 View::Duplicates => {
1980 if let Some((filtered, _)) = self.get_filtered_duplicates() {
1981 self.duplicates_state.selected_group = filtered.len().saturating_sub(1);
1982 }
1983 }
1984 View::Age => {
1985 if let Some(filtered) = self.get_filtered_stale_dirs() {
1986 self.selected_stale_dir = filtered.len().saturating_sub(1);
1987 }
1988 }
1989 View::Errors => {
1990 self.selected_warning = self.warnings.len().saturating_sub(1);
1991 }
1992 }
1993 }
1994
1995 fn expand_selected(&mut self) {
1996 if let Some((node, root_path)) = self.get_view_root_node() {
1997 let items = TreeView::new(node, &root_path, &self.theme, &self.marked, &self.clipboard).flatten(&self.tree_state);
1998 if let Some(item) = items.get(self.tree_state.selected) {
1999 if matches!(item.node.kind, crate::ui::VisibleNodeKind::Directory { .. }) {
2001 let needs_lazy_load = if let Some(tree) = &self.tree {
2002 if let Some(node) = Self::find_node_at_path(&tree.root, &item.path, &tree.root_path) {
2003 node.children.is_empty()
2004 } else {
2005 false
2006 }
2007 } else {
2008 false
2009 };
2010 if needs_lazy_load {
2011 self.lazy_load_directory(&item.path);
2012 }
2013 }
2014 self.tree_state.expand(&item.path);
2015 self.update_cached_tree_len();
2016 }
2017 }
2018 }
2019
2020 fn collapse_selected(&mut self) {
2023 if let Some((node, root_path)) = self.get_view_root_node() {
2024 let items = TreeView::new(node, &root_path, &self.theme, &self.marked, &self.clipboard).flatten(&self.tree_state);
2025 if let Some(item) = items.get(self.tree_state.selected) {
2026 let is_expanded_dir = matches!(
2028 item.node.kind,
2029 crate::ui::VisibleNodeKind::Directory { expanded: true }
2030 );
2031
2032 if is_expanded_dir {
2033 self.tree_state.collapse(&item.path);
2035 self.update_cached_tree_len();
2036 } else {
2037 self.navigate_back();
2039 }
2040 }
2041 }
2042 }
2043
2044 fn toggle_selected(&mut self) {
2045 if let Some((node, root_path)) = self.get_view_root_node() {
2046 let items = TreeView::new(node, &root_path, &self.theme, &self.marked, &self.clipboard).flatten(&self.tree_state);
2047 if let Some(item) = items.get(self.tree_state.selected) {
2048 self.tree_state.toggle_expand(&item.path);
2049 self.update_cached_tree_len();
2050 }
2051 }
2052 }
2053
2054 fn get_selected_info(&self) -> Option<SelectedInfo> {
2055 let tree = self.tree.as_ref()?;
2056 let (view_node, view_path) = self.get_view_root_node()?;
2057
2058 match self.layout_mode {
2059 LayoutMode::Tree => {
2060 let items = TreeView::new(view_node, &view_path, &self.theme, &self.marked, &self.clipboard).flatten(&self.tree_state);
2061 let item = items.get(self.tree_state.selected)?;
2062 let node = Self::find_node_at_path(&tree.root, &item.path, &tree.root_path)?;
2063
2064 Some(SelectedInfo {
2065 name: item.node.name.clone(),
2066 path: item.path.clone(),
2067 size: node.size,
2068 file_count: node.file_count(),
2069 dir_count: node.dir_count(),
2070 modified: node.timestamps.modified,
2071 is_dir: node.is_dir(),
2072 })
2073 }
2074 LayoutMode::Miller => {
2075 let child = view_node.children.get(self.miller_state.selected)?;
2076 let path = self.view_root.join(&*child.name);
2077
2078 Some(SelectedInfo {
2079 name: child.name.to_string().into(),
2080 path,
2081 size: child.size,
2082 file_count: child.file_count(),
2083 dir_count: child.dir_count(),
2084 modified: child.timestamps.modified,
2085 is_dir: child.is_dir(),
2086 })
2087 }
2088 }
2089 }
2090
2091 fn is_selected_file(&self) -> bool {
2093 match self.layout_mode {
2094 LayoutMode::Tree => {
2095 if let Some((view_node, view_path)) = self.get_view_root_node() {
2096 let items = TreeView::new(view_node, &view_path, &self.theme, &self.marked, &self.clipboard)
2097 .flatten(&self.tree_state);
2098 if let Some(item) = items.get(self.tree_state.selected) {
2099 return !matches!(item.node.kind, crate::ui::VisibleNodeKind::Directory { .. });
2100 }
2101 }
2102 false
2103 }
2104 LayoutMode::Miller => {
2105 if let Some((view_node, _)) = self.get_view_root_node() {
2106 if let Some(child) = view_node.children.get(self.miller_state.selected) {
2107 return !child.is_dir();
2108 }
2109 }
2110 false
2111 }
2112 }
2113 }
2114
2115 fn get_selected_path(&self) -> Option<PathBuf> {
2117 match self.layout_mode {
2118 LayoutMode::Tree => {
2119 if let Some((view_node, view_path)) = self.get_view_root_node() {
2120 let items = TreeView::new(view_node, &view_path, &self.theme, &self.marked, &self.clipboard)
2121 .flatten(&self.tree_state);
2122 return items.get(self.tree_state.selected).map(|item| item.path.clone());
2123 }
2124 None
2125 }
2126 LayoutMode::Miller => {
2127 if let Some((view_node, _)) = self.get_view_root_node() {
2128 if let Some(child) = view_node.children.get(self.miller_state.selected) {
2129 return Some(self.view_root.join(&*child.name));
2130 }
2131 }
2132 None
2133 }
2134 }
2135 }
2136
2137 fn open_selected_file(&mut self) {
2139 let Some(path) = self.get_selected_path() else {
2140 return;
2141 };
2142
2143 let openers = &self.user_settings.openers;
2145 let editor_config = &self.user_settings.editor;
2146
2147 match crate::opener::open_file(&path, openers, editor_config) {
2148 crate::opener::OpenResult::Opened => {
2149 }
2151 crate::opener::OpenResult::NeedsSuspend(cmd) => {
2152 self.suspend_and_run(cmd);
2154 }
2155 crate::opener::OpenResult::NotSupported(msg) => {
2156 self.error = Some(msg);
2157 }
2158 crate::opener::OpenResult::Error(msg) => {
2159 self.error = Some(msg);
2160 }
2161 }
2162 }
2163
2164 fn suspend_and_run(&mut self, cmd: std::process::Command) {
2166 self.pending_suspend_command = Some(cmd);
2170 }
2171
2172 fn drill_into_selected(&mut self) {
2174 let Some((view_node, view_path)) = self.get_view_root_node() else {
2175 return;
2176 };
2177
2178 let items = TreeView::new(view_node, &view_path, &self.theme, &self.marked, &self.clipboard).flatten(&self.tree_state);
2179 let Some(item) = items.get(self.tree_state.selected) else {
2180 return;
2181 };
2182
2183 if !matches!(
2184 item.node.kind,
2185 crate::ui::VisibleNodeKind::Directory { .. }
2186 ) {
2187 return;
2188 }
2189
2190 let needs_lazy_load = if let Some(tree) = &self.tree {
2192 if let Some(node) = Self::find_node_at_path(&tree.root, &item.path, &tree.root_path) {
2193 node.children.is_empty()
2194 } else {
2195 true
2196 }
2197 } else {
2198 true
2199 };
2200
2201 if needs_lazy_load {
2203 self.lazy_load_directory(&item.path);
2204 }
2205
2206 if item.path == self.view_root {
2207 self.tree_state.expand(&item.path);
2208 self.update_cached_tree_len();
2209 return;
2210 }
2211
2212 let saved_expanded = self.tree_state.expanded.clone();
2213 let saved_selected = self.tree_state.selected;
2214 self.view_history
2215 .push((self.view_root.clone(), saved_selected, saved_expanded));
2216
2217 let target_path = item.path.clone();
2218
2219 if let Some(forward_idx) = self.forward_history.iter().rposition(|(p, _, _)| p == &target_path) {
2221 let (_, fwd_selected, fwd_expanded) = self.forward_history.remove(forward_idx);
2222 self.forward_history.truncate(forward_idx);
2224
2225 self.view_root = target_path;
2226 self.tree_state.selected = fwd_selected;
2227 self.tree_state.offset = 0;
2228 self.tree_state.expanded = fwd_expanded;
2229 self.tree_state.expand(&self.view_root);
2230 self.update_cached_tree_len();
2231 self.sync_to_active_tab();
2232 return;
2233 }
2234
2235 self.forward_history.clear();
2237
2238 self.view_root = target_path;
2239
2240 self.tree_state.selected = 0;
2241 self.tree_state.offset = 0;
2242 self.tree_state.expand(&self.view_root);
2243 self.update_cached_tree_len();
2244 self.sync_to_active_tab();
2245 }
2246
2247 fn drill_into_miller_selected(&mut self) {
2249 let Some((view_node, _)) = self.get_view_root_node() else {
2250 return;
2251 };
2252
2253 let Some(selected_child) = view_node.children.get(self.miller_state.selected) else {
2254 return;
2255 };
2256
2257 if !selected_child.is_dir() {
2259 return;
2260 }
2261
2262 let target_path = self.view_root.join(&*selected_child.name);
2263
2264 if selected_child.children.is_empty() {
2266 self.lazy_load_directory(&target_path);
2267 }
2268
2269 let saved_expanded = self.tree_state.expanded.clone();
2271 let saved_selected = self.miller_state.selected;
2272 self.view_history
2273 .push((self.view_root.clone(), saved_selected, saved_expanded));
2274
2275 if let Some(forward_idx) = self.forward_history.iter().rposition(|(p, _, _)| p == &target_path) {
2277 let (_, fwd_selected, fwd_expanded) = self.forward_history.remove(forward_idx);
2278 self.forward_history.truncate(forward_idx);
2280
2281 self.view_root = target_path;
2282 self.miller_state.selected = fwd_selected;
2283 self.miller_state.offset = 0;
2284 self.tree_state.expanded = fwd_expanded;
2285 self.update_cached_miller_len();
2286 self.miller_state.ensure_visible(20);
2287 self.update_preview();
2288 self.sync_to_active_tab();
2289 return;
2290 }
2291
2292 self.forward_history.clear();
2294
2295 self.view_root = target_path;
2297 self.miller_state.reset();
2298 self.update_cached_miller_len();
2299 self.update_preview();
2300 self.sync_to_active_tab();
2301 }
2302
2303 fn lazy_load_directory(&mut self, path: &PathBuf) {
2306 if let Some(cached_tree) = self.scanned_cache.get(path).cloned() {
2308 if let Some(tree) = &mut self.tree {
2310 if let Some(node) = Self::find_node_mut(&mut tree.root, path, &tree.root_path) {
2311 *node = cached_tree.root;
2313 }
2314 }
2315 return;
2316 }
2317
2318 if let Ok(quick_tree) = gravityfile_scan::quick_list(path) {
2321 if let Some(tree) = &mut self.tree {
2323 if let Some(node) = Self::find_node_mut(&mut tree.root, path, &tree.root_path) {
2324 node.children = quick_tree.root.children;
2325 }
2326 }
2327 }
2328 }
2329
2330 fn find_node_mut<'a>(
2332 node: &'a mut gravityfile_core::FileNode,
2333 target: &PathBuf,
2334 current_path: &PathBuf,
2335 ) -> Option<&'a mut gravityfile_core::FileNode> {
2336 if current_path == target {
2337 return Some(node);
2338 }
2339
2340 for child in &mut node.children {
2341 let child_path = current_path.join(&*child.name);
2342 if target.starts_with(&child_path) {
2343 if let Some(found) = Self::find_node_mut(child, target, &child_path) {
2344 return Some(found);
2345 }
2346 }
2347 }
2348
2349 None
2350 }
2351
2352 fn navigate_back(&mut self) {
2355 let current_selected = match self.layout_mode {
2357 LayoutMode::Tree => self.tree_state.selected,
2358 LayoutMode::Miller => self.miller_state.selected,
2359 };
2360 let current_state = (
2361 self.view_root.clone(),
2362 current_selected,
2363 self.tree_state.expanded.clone(),
2364 );
2365
2366 if let Some((prev_root, saved_selected, saved_expanded)) = self.view_history.pop() {
2368 self.forward_history.push(current_state);
2370 self.view_root = prev_root;
2371 self.tree_state.expanded = saved_expanded;
2372
2373 match self.layout_mode {
2375 LayoutMode::Tree => {
2376 self.tree_state.selected = saved_selected;
2377 self.tree_state.offset = 0;
2378 self.update_cached_tree_len();
2379 }
2380 LayoutMode::Miller => {
2381 self.miller_state.selected = saved_selected;
2382 self.miller_state.offset = 0;
2383 self.update_cached_miller_len();
2384 }
2385 }
2386 self.sync_to_active_tab();
2387 return;
2388 }
2389
2390 let Some(parent) = self.view_root.parent() else {
2392 return; };
2394
2395 let parent = parent.to_path_buf();
2396 let dir_we_came_from = self.view_root.clone();
2397
2398 self.forward_history.push(current_state);
2400
2401 let parent_in_tree = parent.starts_with(&self.path) || parent == self.path;
2403
2404 if parent_in_tree {
2405 self.tree_state.collapse(&dir_we_came_from);
2407 self.view_root = parent;
2408 self.tree_state.expand(&self.view_root);
2409 } else {
2410 self.navigate_to_parent_beyond_scan_root(&parent, &dir_we_came_from);
2412 return;
2413 }
2414
2415 self.select_child_by_name(&dir_we_came_from);
2417 self.sync_to_active_tab();
2418 }
2419
2420 fn navigate_to_parent_beyond_scan_root(&mut self, parent: &PathBuf, dir_we_came_from: &PathBuf) {
2423 if let Some(tree) = &self.tree {
2425 if self.has_full_scan {
2426 self.scanned_cache.insert(tree.root_path.clone(), tree.clone());
2427 }
2428 }
2429
2430 let Ok(mut parent_tree) = gravityfile_scan::quick_list(parent) else {
2432 return; };
2434
2435 self.merge_cached_scans_into_tree(&mut parent_tree.root, parent);
2437
2438 self.path = parent.clone();
2440 self.tree = Some(parent_tree);
2441 self.view_root = parent.clone();
2442 self.view_history.clear();
2443 self.has_full_scan = false;
2447
2448 self.tree_state = TreeState::new(parent.clone());
2450
2451 self.update_cached_parent();
2453
2454 self.select_child_by_name(dir_we_came_from);
2456 self.sync_to_active_tab();
2457 }
2458
2459 fn merge_cached_scans_into_tree(&self, node: &mut gravityfile_core::FileNode, node_path: &PathBuf) {
2463 for child in &mut node.children {
2464 if child.is_dir() {
2465 let child_path = node_path.join(&*child.name);
2466 if let Some(cached_tree) = self.scanned_cache.get(&child_path) {
2467 *child = cached_tree.root.clone();
2469 }
2470 }
2471 }
2472 }
2473
2474 fn select_child_by_name(&mut self, target_path: &PathBuf) {
2476 let dir_name = target_path
2477 .file_name()
2478 .and_then(|n| n.to_str())
2479 .unwrap_or("");
2480
2481 match self.layout_mode {
2482 LayoutMode::Tree => {
2483 if let Some((node, root_path)) = self.get_view_root_node() {
2485 let items = TreeView::new(node, &root_path, &self.theme, &self.marked, &self.clipboard)
2486 .flatten(&self.tree_state);
2487 if let Some(idx) = items.iter().position(|item| {
2488 item.path.file_name().and_then(|n| n.to_str()) == Some(dir_name)
2489 }) {
2490 self.tree_state.selected = idx;
2491 } else {
2492 self.tree_state.selected = 0;
2493 }
2494 } else {
2495 self.tree_state.selected = 0;
2496 }
2497 self.tree_state.offset = 0;
2498 self.update_cached_tree_len();
2499 }
2500 LayoutMode::Miller => {
2501 if let Some((node, _)) = self.get_view_root_node() {
2503 let idx = node
2504 .children
2505 .iter()
2506 .position(|c| c.name.as_str() == dir_name)
2507 .unwrap_or(0);
2508 self.miller_state.selected = idx;
2509 } else {
2510 self.miller_state.selected = 0;
2511 }
2512 self.miller_state.offset = 0;
2513 self.update_cached_miller_len();
2514 }
2515 }
2516 }
2517
2518 fn find_node_at_path<'a>(
2520 node: &'a gravityfile_core::FileNode,
2521 target_path: &PathBuf,
2522 current_path: &PathBuf,
2523 ) -> Option<&'a gravityfile_core::FileNode> {
2524 if current_path == target_path {
2525 return Some(node);
2526 }
2527 for child in &node.children {
2528 let child_path = current_path.join(&*child.name);
2529 if let Some(found) = Self::find_node_at_path(child, target_path, &child_path) {
2530 return Some(found);
2531 }
2532 }
2533 None
2534 }
2535
2536 fn get_view_root_node(&self) -> Option<(&gravityfile_core::FileNode, PathBuf)> {
2538 let tree = self.tree.as_ref()?;
2539 let node = Self::find_node_at_path(&tree.root, &self.view_root, &tree.root_path)?;
2540 Some((node, self.view_root.clone()))
2541 }
2542
2543 fn get_parent_node(&self) -> Option<&gravityfile_core::FileNode> {
2545 let tree = self.tree.as_ref()?;
2546 let parent_path = self.view_root.parent()?.to_path_buf();
2547
2548 if parent_path.starts_with(&tree.root_path) || parent_path == tree.root_path {
2550 return Self::find_node_at_path(&tree.root, &parent_path, &tree.root_path);
2551 }
2552
2553 if self.view_root == tree.root_path {
2555 if let Some(cached) = &self.cached_parent_tree {
2556 return Some(&cached.root);
2557 }
2558 }
2559
2560 None
2561 }
2562
2563 fn update_cached_parent(&mut self) {
2566 let Some(tree) = &self.tree else { return };
2567
2568 if self.view_root == tree.root_path {
2570 if let Some(parent_path) = self.view_root.parent() {
2571 let parent_path_buf = parent_path.to_path_buf();
2572 if let Ok(mut parent_tree) = gravityfile_scan::quick_list(&parent_path_buf) {
2574 self.merge_cached_scans_into_tree(&mut parent_tree.root, &parent_path_buf);
2577 self.cached_parent_tree = Some(parent_tree);
2578 }
2579 }
2580 } else {
2581 self.cached_parent_tree = None;
2583 }
2584 }
2585
2586 fn get_current_dir_name(&self) -> Option<String> {
2588 self.view_root
2589 .file_name()
2590 .and_then(|n| n.to_str())
2591 .map(|s| s.to_string())
2592 }
2593
2594 fn handle_command_input(&mut self, key: crossterm::event::KeyEvent) {
2596 match self.command_input.handle_key(key) {
2597 CommandKeyResult::Continue => {}
2598 CommandKeyResult::Cancel => {
2599 self.mode = AppMode::Normal;
2600 }
2601 CommandKeyResult::Execute(cmd) => {
2602 self.mode = AppMode::Normal;
2603 self.execute_command(&cmd);
2604 }
2605 }
2606 }
2607
2608 fn execute_command(&mut self, cmd: &str) {
2610 match parse_command(cmd) {
2611 CommandAction::None => {}
2612 CommandAction::Quit => {
2613 self.mode = AppMode::Quit;
2614 }
2615 CommandAction::Refresh => {
2616 self.marked.clear();
2617 self.start_scan();
2618 }
2619 CommandAction::NavigateTo(target) => {
2620 self.cd_to_path(&target);
2621 }
2622 CommandAction::GoToRoot => {
2623 self.go_to_root();
2624 }
2625 CommandAction::NavigateBack => {
2626 self.navigate_back();
2627 }
2628 CommandAction::ShowHelp => {
2629 self.mode = AppMode::Help;
2630 }
2631 CommandAction::SwitchView(view) => {
2632 self.view = view;
2633 }
2634 CommandAction::ClearMarks => {
2635 self.marked.clear();
2636 }
2637 CommandAction::ToggleDetails => {
2638 self.show_details = !self.show_details;
2639 }
2640 CommandAction::SetTheme(theme_cmd) => match theme_cmd {
2641 ThemeCommand::Dark => self.theme = Theme::dark(),
2642 ThemeCommand::Light => self.theme = Theme::light(),
2643 ThemeCommand::Toggle => self.theme = self.theme.toggle(),
2644 },
2645 CommandAction::SetLayout(layout_cmd) => {
2646 match layout_cmd {
2647 LayoutCommand::Tree => {
2648 if self.layout_mode != LayoutMode::Tree {
2649 self.sync_layout_selection();
2651 }
2652 }
2653 LayoutCommand::Miller => {
2654 if self.layout_mode != LayoutMode::Miller {
2655 self.sync_layout_selection();
2657 }
2658 }
2659 LayoutCommand::Toggle => {
2660 self.sync_layout_selection();
2661 }
2662 }
2663 }
2664 CommandAction::SetSort(sort_cmd) => {
2665 match sort_cmd {
2666 SortCommand::SizeDesc => self.sort_mode = SortMode::SizeDescending,
2667 SortCommand::SizeAsc => self.sort_mode = SortMode::SizeAscending,
2668 SortCommand::NameAsc => self.sort_mode = SortMode::NameAscending,
2669 SortCommand::NameDesc => self.sort_mode = SortMode::NameDescending,
2670 SortCommand::DateDesc => self.sort_mode = SortMode::ModifiedDescending,
2671 SortCommand::DateAsc => self.sort_mode = SortMode::ModifiedAscending,
2672 SortCommand::CountDesc => self.sort_mode = SortMode::CountDescending,
2673 SortCommand::CountAsc => self.sort_mode = SortMode::CountAscending,
2674 SortCommand::Cycle => self.sort_mode = self.sort_mode.next(),
2675 SortCommand::Reverse => self.sort_mode = self.sort_mode.reverse(),
2676 }
2677 self.apply_sort();
2678 }
2679 CommandAction::Yank => {
2680 self.yank_selection();
2681 }
2682 CommandAction::Cut => {
2683 self.cut_selection();
2684 }
2685 CommandAction::Paste => {
2686 self.paste_clipboard();
2687 }
2688 CommandAction::Delete => {
2689 if !self.marked.is_empty() {
2690 self.mode = AppMode::ConfirmDelete;
2691 }
2692 }
2693 CommandAction::Rename(name) => {
2694 if let Some(name) = name {
2695 if let Some(path) = self.get_selected_path() {
2697 self.execute_rename(path, name);
2698 }
2699 } else {
2700 self.start_rename();
2702 }
2703 }
2704 CommandAction::CreateFile(name) => {
2705 if let Some(name) = name {
2706 self.execute_create_file(self.view_root.clone(), name);
2708 } else {
2709 self.start_create_file();
2711 }
2712 }
2713 CommandAction::CreateDirectory(name) => {
2714 if let Some(name) = name {
2715 self.execute_create_directory(self.view_root.clone(), name);
2717 } else {
2718 self.start_create_directory();
2720 }
2721 }
2722 CommandAction::Take(name) => {
2723 if let Some(name) = name {
2724 self.execute_take(self.view_root.clone(), name);
2726 } else {
2727 self.start_take();
2729 }
2730 }
2731 CommandAction::Undo => {
2732 self.execute_undo();
2733 }
2734 }
2735 }
2736
2737 fn go_to_root(&mut self) {
2739 self.view_root = self.path.clone();
2740 self.view_history.clear();
2741 self.forward_history.clear();
2742 self.tree_state.selected = 0;
2743 self.tree_state.offset = 0;
2744 self.tree_state.expand(&self.view_root);
2745 self.update_cached_tree_len();
2746 self.sync_to_active_tab();
2747 }
2748
2749 fn cd_to_path(&mut self, target: &str) {
2751 if target == "/" || target == "~" {
2752 self.go_to_root();
2753 return;
2754 }
2755 if target == ".." {
2756 self.navigate_back();
2757 return;
2758 }
2759
2760 let Some(tree) = &self.tree else {
2761 return;
2762 };
2763
2764 let target_path = if target.starts_with('/') {
2765 let relative = target.trim_start_matches('/');
2766 self.path.join(relative)
2767 } else {
2768 self.view_root.join(target)
2769 };
2770
2771 if let Some(node) = Self::find_node_at_path(&tree.root, &target_path, &tree.root_path) {
2772 if node.is_dir() {
2773 let saved_expanded = self.tree_state.expanded.clone();
2774 let saved_selected = self.tree_state.selected;
2775 self.view_history
2776 .push((self.view_root.clone(), saved_selected, saved_expanded));
2777
2778 self.view_root = target_path;
2779 self.tree_state.selected = 0;
2780 self.tree_state.offset = 0;
2781 self.tree_state.expand(&self.view_root);
2782 self.update_cached_tree_len();
2783 self.sync_to_active_tab();
2784 }
2785 }
2786 }
2787
2788 pub fn is_marked(&self, path: &PathBuf) -> bool {
2790 self.marked.contains(path)
2791 }
2792
2793 fn cycle_sort_mode(&mut self) {
2795 self.sort_mode = self.sort_mode.next();
2796 self.apply_sort();
2797 }
2798
2799 fn reverse_sort_mode(&mut self) {
2801 self.sort_mode = self.sort_mode.reverse();
2802 self.apply_sort();
2803 }
2804
2805 fn sync_to_active_tab(&mut self) {
2807 if let Some(tab) = self.tab_manager.active_tab_mut() {
2808 tab.view_root = self.view_root.clone();
2809 tab.label = self.view_root
2810 .file_name()
2811 .map(|n| n.to_string_lossy().to_string())
2812 .unwrap_or_else(|| self.view_root.to_string_lossy().to_string());
2813 }
2814 }
2815
2816 fn sync_from_active_tab(&mut self) {
2818 if let Some(tab) = self.tab_manager.active_tab() {
2819 if tab.view_root != self.view_root {
2821 self.view_root = tab.view_root.clone();
2822 self.tree_state.selected = 0;
2824 self.tree_state.offset = 0;
2825 self.tree_state.expand(&self.view_root);
2826 self.miller_state.reset();
2827 match self.layout_mode {
2828 LayoutMode::Tree => self.update_cached_tree_len(),
2829 LayoutMode::Miller => self.update_cached_miller_len(),
2830 }
2831 }
2832 }
2833 }
2834
2835 fn start_search(&mut self) {
2837 self.update_search_paths();
2839 self.search_state.activate();
2840 self.mode = AppMode::Search;
2841 }
2842
2843 fn update_search_paths(&mut self) {
2845 let mut paths = Vec::new();
2846
2847 if let Some(tree) = &self.tree {
2848 Self::collect_paths_recursive(
2849 &tree.root,
2850 &tree.root_path,
2851 &self.view_root,
2852 &mut paths,
2853 );
2854 }
2855
2856 self.search_state.update_paths(paths);
2857 }
2858
2859 fn collect_paths_recursive(
2861 node: &gravityfile_core::FileNode,
2862 current_path: &PathBuf,
2863 view_root: &PathBuf,
2864 paths: &mut Vec<(PathBuf, String, bool)>,
2865 ) {
2866 if !current_path.starts_with(view_root) && current_path != view_root {
2868 return;
2869 }
2870
2871 let display = if current_path == view_root {
2873 ".".to_string()
2874 } else {
2875 current_path
2876 .strip_prefix(view_root)
2877 .map(|p| p.to_string_lossy().to_string())
2878 .unwrap_or_else(|_| node.name.to_string())
2879 };
2880
2881 paths.push((current_path.clone(), display, node.is_dir()));
2882
2883 for child in &node.children {
2885 let child_path = current_path.join(&*child.name);
2886 Self::collect_paths_recursive(child, &child_path, view_root, paths);
2887 }
2888 }
2889
2890 fn handle_search_input(&mut self, key: crossterm::event::KeyEvent) {
2892 use crossterm::event::{KeyCode, KeyModifiers};
2893
2894 match (key.code, key.modifiers) {
2895 (KeyCode::Esc, _) => {
2897 self.search_state.deactivate();
2898 self.mode = AppMode::Normal;
2899 }
2900 (KeyCode::Enter, _) => {
2902 if let Some(result) = self.search_state.selected_result() {
2903 let target_path = result.path.clone();
2904 self.navigate_to_search_result(&target_path);
2905 }
2906 self.search_state.deactivate();
2907 self.mode = AppMode::Normal;
2908 }
2909 (KeyCode::Up, _) | (KeyCode::Char('k'), KeyModifiers::CONTROL) => {
2911 self.search_state.move_up();
2912 }
2913 (KeyCode::Down, _) | (KeyCode::Char('j'), KeyModifiers::CONTROL) => {
2914 self.search_state.move_down();
2915 }
2916 (KeyCode::Tab, _) => {
2918 self.search_state.cycle_mode();
2919 }
2920 (KeyCode::Left, _) => {
2922 self.search_state.move_cursor_left();
2923 }
2924 (KeyCode::Right, _) => {
2925 self.search_state.move_cursor_right();
2926 }
2927 (KeyCode::Home, _) | (KeyCode::Char('a'), KeyModifiers::CONTROL) => {
2928 self.search_state.move_cursor_start();
2929 }
2930 (KeyCode::End, _) | (KeyCode::Char('e'), KeyModifiers::CONTROL) => {
2931 self.search_state.move_cursor_end();
2932 }
2933 (KeyCode::Backspace, _) => {
2935 self.search_state.delete_char_before();
2936 }
2937 (KeyCode::Delete, _) => {
2938 self.search_state.delete_char_at();
2939 }
2940 (KeyCode::Char('u'), KeyModifiers::CONTROL) => {
2942 self.search_state.clear_query();
2943 }
2944 (KeyCode::Char(c), KeyModifiers::NONE | KeyModifiers::SHIFT) => {
2946 self.search_state.insert_char(c);
2947 }
2948 _ => {}
2949 }
2950 }
2951
2952 fn navigate_to_search_result(&mut self, target: &PathBuf) {
2954 if target.is_dir() {
2957 let saved_expanded = self.tree_state.expanded.clone();
2959 let saved_selected = match self.layout_mode {
2960 LayoutMode::Tree => self.tree_state.selected,
2961 LayoutMode::Miller => self.miller_state.selected,
2962 };
2963 self.view_history.push((self.view_root.clone(), saved_selected, saved_expanded));
2964
2965 self.view_root = target.clone();
2967 self.tree_state.selected = 0;
2968 self.tree_state.offset = 0;
2969 self.tree_state.expand(&self.view_root);
2970 self.miller_state.reset();
2971
2972 match self.layout_mode {
2973 LayoutMode::Tree => self.update_cached_tree_len(),
2974 LayoutMode::Miller => self.update_cached_miller_len(),
2975 }
2976 self.sync_to_active_tab();
2977 } else {
2978 if let Some(parent) = target.parent() {
2980 let saved_expanded = self.tree_state.expanded.clone();
2982 let saved_selected = match self.layout_mode {
2983 LayoutMode::Tree => self.tree_state.selected,
2984 LayoutMode::Miller => self.miller_state.selected,
2985 };
2986 self.view_history.push((self.view_root.clone(), saved_selected, saved_expanded));
2987
2988 self.view_root = parent.to_path_buf();
2990 self.tree_state.expand(&self.view_root);
2991
2992 let file_name = target.file_name().and_then(|n| n.to_str()).map(|s| s.to_string());
2994 let target_clone = target.clone();
2995
2996 match self.layout_mode {
2997 LayoutMode::Tree => {
2998 self.tree_state.selected = 0;
3000 self.update_cached_tree_len();
3001
3002 if let Some((node, _)) = self.get_view_root_node() {
3004 let items = TreeView::new(node, &self.view_root, &self.theme, &self.marked, &self.clipboard)
3005 .flatten(&self.tree_state);
3006 if let Some(pos) = items.iter().position(|i| i.path == target_clone) {
3007 self.tree_state.selected = pos;
3008 }
3009 }
3010 }
3011 LayoutMode::Miller => {
3012 if let (Some(file_name), Some((node, _))) = (&file_name, self.get_view_root_node()) {
3014 let idx = node
3015 .children
3016 .iter()
3017 .position(|c| c.name.as_str() == file_name)
3018 .unwrap_or(0);
3019 self.miller_state.selected = idx;
3020 }
3021 self.update_cached_miller_len();
3022 }
3023 }
3024 self.sync_to_active_tab();
3025 }
3026 }
3027 }
3028
3029 fn handle_settings_input(&mut self, action: KeyAction) {
3031 match action {
3032 KeyAction::Cancel | KeyAction::Quit => {
3033 self.settings_state = None;
3035 self.mode = AppMode::Normal;
3036 }
3037 KeyAction::MoveUp => {
3038 if let Some(ref mut state) = self.settings_state {
3039 state.move_up();
3040 }
3041 }
3042 KeyAction::MoveDown => {
3043 if let Some(ref mut state) = self.settings_state {
3044 state.move_down();
3045 }
3046 }
3047 KeyAction::ToggleMark | KeyAction::DrillDown => {
3048 if let Some(ref mut state) = self.settings_state {
3050 state.toggle_selected();
3051 }
3052 }
3053 KeyAction::Sort => {
3054 if let Some(ref state) = self.settings_state {
3056 if state.dirty {
3057 self.user_settings = state.settings.clone();
3059 if let Err(e) = self.user_settings.save() {
3060 self.error = Some(format!("Failed to save settings: {}", e));
3061 } else {
3062 self.scan_on_startup = self.user_settings.scan_on_startup;
3064 }
3065 }
3066 }
3067 self.settings_state = None;
3068 self.mode = AppMode::Normal;
3069 }
3070 _ => {}
3071 }
3072 }
3073
3074 fn apply_sort(&mut self) {
3076 if let Some(ref mut tree) = self.tree {
3077 Self::sort_node_recursive(&mut tree.root, self.sort_mode);
3078 match self.layout_mode {
3080 LayoutMode::Tree => self.update_cached_tree_len(),
3081 LayoutMode::Miller => self.update_cached_miller_len(),
3082 }
3083 }
3084 }
3085
3086 fn sort_node_recursive(node: &mut gravityfile_core::FileNode, mode: SortMode) {
3088 node.children.sort_by(|a, b| {
3090 match mode {
3091 SortMode::SizeDescending => b.size.cmp(&a.size),
3092 SortMode::SizeAscending => a.size.cmp(&b.size),
3093 SortMode::NameAscending => a.name.cmp(&b.name),
3094 SortMode::NameDescending => b.name.cmp(&a.name),
3095 SortMode::ModifiedDescending => b.timestamps.modified.cmp(&a.timestamps.modified),
3096 SortMode::ModifiedAscending => a.timestamps.modified.cmp(&b.timestamps.modified),
3097 SortMode::CountDescending => {
3098 let a_count = a.children.len();
3099 let b_count = b.children.len();
3100 b_count.cmp(&a_count)
3101 }
3102 SortMode::CountAscending => {
3103 let a_count = a.children.len();
3104 let b_count = b.children.len();
3105 a_count.cmp(&b_count)
3106 }
3107 }
3108 });
3109
3110 for child in &mut node.children {
3112 Self::sort_node_recursive(child, mode);
3113 }
3114 }
3115
3116 fn get_filtered_duplicates(
3118 &self,
3119 ) -> Option<(Vec<&gravityfile_analyze::DuplicateGroup>, u64)> {
3120 let dups = self.duplicates.as_ref()?;
3121
3122 if self.view_root == self.path {
3123 let total: u64 = dups.groups.iter().map(|g| g.wasted_bytes).sum();
3124 return Some((dups.groups.iter().collect(), total));
3125 }
3126
3127 let filtered: Vec<&gravityfile_analyze::DuplicateGroup> = dups
3128 .groups
3129 .iter()
3130 .filter(|g| g.paths.iter().any(|p| p.starts_with(&self.view_root)))
3131 .collect();
3132
3133 let total_wasted: u64 = filtered
3134 .iter()
3135 .map(|g| {
3136 let paths_in_view = g
3137 .paths
3138 .iter()
3139 .filter(|p| p.starts_with(&self.view_root))
3140 .count();
3141 if paths_in_view > 1 {
3142 g.size * (paths_in_view as u64 - 1)
3143 } else {
3144 0
3145 }
3146 })
3147 .sum();
3148
3149 Some((filtered, total_wasted))
3150 }
3151
3152 fn get_filtered_stale_dirs(&self) -> Option<Vec<&gravityfile_analyze::StaleDirectory>> {
3154 let age = self.age_report.as_ref()?;
3155
3156 if self.view_root == self.path {
3157 return Some(age.stale_directories.iter().collect());
3158 }
3159
3160 let filtered: Vec<&gravityfile_analyze::StaleDirectory> = age
3161 .stale_directories
3162 .iter()
3163 .filter(|d| d.path.starts_with(&self.view_root))
3164 .collect();
3165
3166 Some(filtered)
3167 }
3168}
3169
3170impl Widget for &App {
3171 fn render(self, area: Rect, buf: &mut Buffer) {
3172 let ctx = RenderContext {
3173 mode: self.mode,
3174 view: self.view,
3175 theme: &self.theme,
3176 path: &self.path,
3177 view_root: &self.view_root,
3178 show_details: self.show_details,
3179 tree: self.tree.as_ref(),
3180 tree_state: &self.tree_state,
3181 layout_mode: self.layout_mode,
3182 miller_state: &self.miller_state,
3183 scan_progress: self.scan_progress.as_ref(),
3184 deletion_progress: self.deletion_progress.as_ref(),
3185 duplicates: self.duplicates.as_ref(),
3186 age_report: self.age_report.as_ref(),
3187 warnings: &self.warnings,
3188 duplicates_state: &self.duplicates_state,
3189 selected_stale_dir: self.selected_stale_dir,
3190 selected_warning: self.selected_warning,
3191 marked: &self.marked,
3192 deletion_message: self.deletion_message.as_ref(),
3193 operation_message: self.operation_message.as_ref(),
3194 error: self.error.as_deref(),
3195 command_input: self.command_input.buffer(),
3196 command_cursor: self.command_input.cursor(),
3197 input_state: self.input_state.as_ref(),
3198 operation_progress: self.operation_progress.as_ref(),
3199 pending_conflict: self.pending_conflict.as_ref(),
3200 clipboard: &self.clipboard,
3201 get_path_size: Box::new(|p| self.get_path_size(p)),
3202 get_selected_info: self.get_selected_info(),
3203 get_view_root_node: self.get_view_root_node(),
3204 get_parent_node: self.get_parent_node(),
3205 current_dir_name: self.get_current_dir_name(),
3206 get_filtered_duplicates: self.get_filtered_duplicates(),
3207 get_filtered_stale_dirs: self.get_filtered_stale_dirs(),
3208 sort_mode: self.sort_mode,
3209 search_state: &self.search_state,
3210 tab_manager: &self.tab_manager,
3211 preview_content: &self.preview_state.content,
3212 preview_mode: self.preview_state.mode,
3213 has_full_scan: self.has_full_scan,
3214 settings_state: self.settings_state.as_ref(),
3215 };
3216
3217 render_app(&ctx, area, buf);
3218 }
3219}