1use std::{
2 fs, io,
3 path::{Path, PathBuf},
4};
5
6use super::{node::FileNode, tree::FileTree};
7
8#[derive(Clone, Debug)]
10pub struct ExplorerState {
11 pub tree: FileTree,
13 pub cursor_index: usize,
15 pub show_hidden: bool,
17 pub show_sizes: bool,
19 pub filter_text: String,
21 pub width: u16,
23 pub scroll_offset: usize,
25 pub visible_height: u16,
27 pub input_mode: ExplorerInputMode,
29 pub input_buffer: String,
31 pub message: Option<String>,
33 pub clipboard: ExplorerClipboard,
35 pub selection: ExplorerSelection,
37 pub visible: bool,
39 pub popup: FileDetailsPopup,
41 pub enable_colors: bool,
43 pub tree_style: TreeStyle,
45}
46
47#[derive(Clone, Debug, Default, PartialEq, Eq)]
49pub enum TreeStyle {
50 None,
52 Simple,
54 #[default]
56 BoxDrawing,
57}
58
59#[derive(Clone, Debug, Default, PartialEq, Eq)]
61pub enum ExplorerInputMode {
62 #[default]
64 None,
65 CreateFile,
67 CreateDir,
69 Rename,
71 ConfirmDelete,
73 Filter,
75}
76
77#[derive(Clone, Debug, Default, PartialEq, Eq)]
79pub enum ClipboardOperation {
80 #[default]
82 Copy,
83 Cut,
85}
86
87#[derive(Clone, Debug, Default)]
89pub struct ExplorerClipboard {
90 pub paths: Vec<PathBuf>,
92 pub operation: ClipboardOperation,
94}
95
96#[derive(Clone, Debug, Default)]
98pub struct ExplorerSelection {
99 pub selected: std::collections::HashSet<PathBuf>,
101 pub active: bool,
103 pub anchor_index: Option<usize>,
105}
106
107#[derive(Clone, Debug, Default)]
109pub struct FileDetailsPopup {
110 pub visible: bool,
112 pub name: String,
114 pub path: String,
116 pub file_type: String,
118 pub size: Option<String>,
120 pub created: Option<String>,
122 pub modified: Option<String>,
124}
125
126impl ExplorerState {
127 pub fn new(root_path: PathBuf) -> io::Result<Self> {
129 let tree = FileTree::new(root_path)?;
130
131 Ok(Self {
132 tree,
133 cursor_index: 0,
134 show_hidden: false,
135 show_sizes: false,
136 filter_text: String::new(),
137 width: 30,
138 scroll_offset: 0,
139 visible_height: 20, input_mode: ExplorerInputMode::None,
141 input_buffer: String::new(),
142 message: None,
143 clipboard: ExplorerClipboard::default(),
144 selection: ExplorerSelection::default(),
145 visible: false,
146 popup: FileDetailsPopup::default(),
147 enable_colors: true, tree_style: TreeStyle::BoxDrawing, })
150 }
151
152 #[must_use]
154 pub fn is_input_mode(&self) -> bool {
155 self.input_mode != ExplorerInputMode::None
156 }
157
158 pub fn toggle_visibility(&mut self) {
160 self.visible = !self.visible;
161 }
162
163 pub fn show(&mut self) {
165 self.visible = true;
166 }
167
168 pub fn hide(&mut self) {
170 self.visible = false;
171 }
172
173 pub fn start_create_file(&mut self) {
175 self.input_mode = ExplorerInputMode::CreateFile;
176 self.input_buffer.clear();
177 self.message = Some("Create file: ".to_string());
178 tracing::info!(
179 "ExplorerState: start_create_file() called, message set to: {:?}",
180 self.message
181 );
182 }
183
184 pub fn start_create_dir(&mut self) {
186 self.input_mode = ExplorerInputMode::CreateDir;
187 self.input_buffer.clear();
188 self.message = Some("Create directory: ".to_string());
189 }
190
191 pub fn start_rename(&mut self) {
193 let info = self.current_node().map(|n| (n.name.clone(), n.depth));
195 if let Some((name, depth)) = info {
196 if depth == 0 {
198 self.message = Some("Cannot rename root directory".to_string());
199 return;
200 }
201 self.input_mode = ExplorerInputMode::Rename;
202 self.input_buffer = name;
203 self.message = Some("Rename to: ".to_string());
204 }
205 }
206
207 pub fn start_delete(&mut self) {
209 let info = self.current_node().map(|n| (n.name.clone(), n.depth));
211 if let Some((name, depth)) = info {
212 if depth == 0 {
214 self.message = Some("Cannot delete root directory".to_string());
215 return;
216 }
217 self.input_mode = ExplorerInputMode::ConfirmDelete;
218 self.input_buffer.clear();
219 self.message = Some(format!("Delete '{name}'? (y/n): "));
220 }
221 }
222
223 pub fn start_filter(&mut self) {
225 self.input_mode = ExplorerInputMode::Filter;
226 self.input_buffer = self.filter_text.clone();
227 self.message = Some("Filter: ".to_string());
228 }
229
230 pub fn cancel_input(&mut self) {
232 self.input_mode = ExplorerInputMode::None;
233 self.input_buffer.clear();
234 self.message = None;
235 }
236
237 pub fn input_char(&mut self, c: char) {
239 self.input_buffer.push(c);
240 if self.input_mode == ExplorerInputMode::Filter {
242 self.filter_text = self.input_buffer.clone();
243 self.adjust_cursor_after_filter();
244 }
245 }
246
247 pub fn input_backspace(&mut self) {
249 self.input_buffer.pop();
250 if self.input_mode == ExplorerInputMode::Filter {
252 self.filter_text = self.input_buffer.clone();
253 self.adjust_cursor_after_filter();
254 }
255 }
256
257 fn validate_cursor_bounds(&mut self) {
261 let len = self.visible_nodes().len();
262 if len == 0 {
263 self.cursor_index = 0;
264 } else if self.cursor_index >= len {
265 self.cursor_index = len.saturating_sub(1);
266 }
267 }
268
269 fn adjust_cursor_after_filter(&mut self) {
271 self.validate_cursor_bounds();
272 }
273
274 pub fn confirm_input(&mut self) -> io::Result<()> {
276 match self.input_mode {
277 ExplorerInputMode::CreateFile => {
278 self.do_create_file()?;
279 }
280 ExplorerInputMode::CreateDir => {
281 self.do_create_dir()?;
282 }
283 ExplorerInputMode::Rename => {
284 self.do_rename()?;
285 }
286 ExplorerInputMode::ConfirmDelete => {
287 if self.input_buffer.to_lowercase() == "y" {
288 self.do_delete()?;
289 }
290 }
291 ExplorerInputMode::Filter | ExplorerInputMode::None => {
292 }
294 }
295 self.input_mode = ExplorerInputMode::None;
296 self.input_buffer.clear();
297 self.message = None;
298 Ok(())
299 }
300
301 fn get_creation_parent(&self) -> Option<PathBuf> {
303 self.current_node().map_or_else(
304 || Some(self.tree.root_path().to_path_buf()),
305 |node| {
306 if node.is_dir() {
307 Some(node.path.clone())
308 } else {
309 node.path.parent().map(Path::to_path_buf)
310 }
311 },
312 )
313 }
314
315 fn do_create_file(&mut self) -> io::Result<()> {
317 if self.input_buffer.is_empty() {
318 return Ok(());
319 }
320 if let Some(parent) = self.get_creation_parent() {
321 let path = parent.join(&self.input_buffer);
322 fs::File::create(&path)?;
323 self.refresh()?;
324 }
325 Ok(())
326 }
327
328 fn do_create_dir(&mut self) -> io::Result<()> {
330 if self.input_buffer.is_empty() {
331 return Ok(());
332 }
333 if let Some(parent) = self.get_creation_parent() {
334 let path = parent.join(&self.input_buffer);
335 fs::create_dir(&path)?;
336 self.refresh()?;
337 }
338 Ok(())
339 }
340
341 fn do_rename(&mut self) -> io::Result<()> {
343 if self.input_buffer.is_empty() {
344 return Ok(());
345 }
346 if let Some(node) = self.current_node() {
347 let old_path = node.path.clone();
348 if let Some(parent) = old_path.parent() {
349 let new_path = parent.join(&self.input_buffer);
350 fs::rename(&old_path, &new_path)?;
351 self.refresh()?;
352 }
353 }
354 Ok(())
355 }
356
357 fn do_delete(&mut self) -> io::Result<()> {
359 if let Some(node) = self.current_node() {
360 let path = node.path.clone();
361 if node.is_dir() {
362 fs::remove_dir_all(&path)?;
363 } else {
364 fs::remove_file(&path)?;
365 }
366 self.refresh()?;
367 }
368 Ok(())
369 }
370
371 #[must_use]
373 pub fn visible_nodes(&self) -> Vec<&FileNode> {
374 let all_nodes = self.tree.flatten(self.show_hidden);
375
376 if self.filter_text.is_empty() {
377 return all_nodes;
378 }
379
380 let filter_lower = self.filter_text.to_lowercase();
382 all_nodes
383 .into_iter()
384 .filter(|node| node.name.to_lowercase().contains(&filter_lower))
385 .collect()
386 }
387
388 #[must_use]
390 pub fn current_node(&self) -> Option<&FileNode> {
391 let nodes = self.visible_nodes();
392 nodes.get(self.cursor_index).copied()
393 }
394
395 #[must_use]
397 pub fn current_path(&self) -> Option<&Path> {
398 self.current_node().map(|n| n.path.as_path())
399 }
400
401 #[allow(clippy::cast_sign_loss)]
403 pub fn move_cursor(&mut self, delta: isize) {
404 let nodes = self.visible_nodes();
405 let len = nodes.len();
406
407 if len == 0 {
408 self.cursor_index = 0;
409 return;
410 }
411
412 let new_index = if delta < 0 {
413 self.cursor_index.saturating_sub(delta.unsigned_abs())
414 } else {
415 self.cursor_index
416 .saturating_add(delta as usize)
417 .min(len.saturating_sub(1))
418 };
419
420 self.cursor_index = new_index;
421 }
422
423 pub const fn move_to_first(&mut self) {
425 self.cursor_index = 0;
426 }
427
428 pub fn move_to_last(&mut self) {
430 let len = self.visible_nodes().len();
431 self.cursor_index = len.saturating_sub(1);
432 }
433
434 #[allow(clippy::cast_possible_wrap)]
436 pub fn move_page(&mut self, height: u16, down: bool) {
437 let page_size = height.saturating_sub(1) as isize;
438 let delta = if down { page_size } else { -page_size };
439 self.move_cursor(delta);
440 }
441
442 pub fn toggle_current(&mut self) -> io::Result<()> {
444 if let Some(path) = self.current_path().map(Path::to_path_buf) {
445 self.tree.toggle(&path)?;
446 }
447 Ok(())
448 }
449
450 pub fn expand_current(&mut self) -> io::Result<()> {
452 if let Some(path) = self.current_path().map(Path::to_path_buf) {
453 self.tree.expand(&path)?;
454 }
455 Ok(())
456 }
457
458 pub fn collapse_current(&mut self) {
460 if let Some(path) = self.current_path().map(Path::to_path_buf) {
461 self.tree.collapse(&path);
462 }
463 }
464
465 pub fn go_to_parent(&mut self) {
471 let current_root = self.tree.root_path().to_path_buf();
472
473 let Some(parent_path) = current_root.parent() else {
475 self.message = Some("Already at root".to_string());
477 return;
478 };
479
480 let old_root_name = current_root.clone();
482
483 if let Err(e) = self.set_root(parent_path.to_path_buf()) {
485 self.message = Some(format!("Failed to navigate to parent: {e}"));
486 return;
487 }
488
489 let nodes = self.visible_nodes();
491 for (i, node) in nodes.iter().enumerate() {
492 if node.path == old_root_name {
493 self.cursor_index = i;
494 return;
495 }
496 }
497 }
498
499 pub fn change_root_to_current(&mut self) {
504 let Some(current) = self.current_node() else {
505 return;
506 };
507
508 let new_root = if current.is_dir() {
509 current.path.clone()
510 } else {
511 match current.path.parent() {
513 Some(parent) => parent.to_path_buf(),
514 None => return,
515 }
516 };
517
518 if let Err(e) = self.set_root(new_root) {
519 self.message = Some(format!("Failed to change root: {e}"));
520 }
521 }
522
523 pub fn set_filter(&mut self, text: String) {
525 self.filter_text = text;
526 self.validate_cursor_bounds();
527 }
528
529 pub fn clear_filter(&mut self) {
531 self.filter_text.clear();
532 }
533
534 pub fn toggle_hidden(&mut self) {
536 self.show_hidden = !self.show_hidden;
537 self.validate_cursor_bounds();
538 }
539
540 pub const fn toggle_sizes(&mut self) {
542 self.show_sizes = !self.show_sizes;
543 }
544
545 pub fn refresh(&mut self) -> io::Result<()> {
547 self.tree.refresh()?;
548 self.validate_cursor_bounds();
549 Ok(())
550 }
551
552 pub fn set_root(&mut self, path: PathBuf) -> io::Result<()> {
554 self.tree = FileTree::new(path)?;
555 self.cursor_index = 0;
556 self.scroll_offset = 0;
557 Ok(())
558 }
559
560 pub const fn set_visible_height(&mut self, height: u16) {
562 self.visible_height = height;
563 }
564
565 pub const fn update_scroll(&mut self) {
567 let height = self.visible_height as usize;
568 if height == 0 {
569 return;
570 }
571
572 if self.cursor_index < self.scroll_offset {
574 self.scroll_offset = self.cursor_index;
575 } else if self.cursor_index >= self.scroll_offset + height {
576 self.scroll_offset = self.cursor_index.saturating_sub(height) + 1;
577 }
578 }
579
580 pub fn show_file_details(&mut self) {
582 use super::node::{format_datetime, format_size};
583
584 let node_info = self.current_node().map(|node| {
586 (
587 node.name.clone(),
588 node.path.display().to_string(),
589 node.is_file(),
590 node.is_dir(),
591 node.is_symlink(),
592 node.size(),
593 node.created(),
594 node.modified(),
595 )
596 });
597
598 if let Some((name, path, is_file, is_dir, is_symlink, size, created, modified)) = node_info
599 {
600 self.popup.visible = true;
601 self.popup.name = name;
602 self.popup.path = path;
603
604 if is_file {
605 self.popup.file_type = "file".to_string();
606 self.popup.size = size.map(format_size);
607 self.popup.created = created.map(format_datetime);
608 self.popup.modified = modified.map(format_datetime);
609 } else if is_dir {
610 self.popup.file_type = "directory".to_string();
611 self.popup.size = None;
612 self.popup.created = None;
613 self.popup.modified = None;
614 } else if is_symlink {
615 self.popup.file_type = "symlink".to_string();
616 self.popup.size = None;
617 self.popup.created = None;
618 self.popup.modified = None;
619 }
620 }
621 }
622
623 pub fn close_popup(&mut self) {
625 self.popup.visible = false;
626 }
627
628 #[must_use]
630 pub const fn is_popup_visible(&self) -> bool {
631 self.popup.visible
632 }
633
634 pub fn sync_popup(&mut self) {
636 if self.popup.visible {
637 self.show_file_details();
638 }
639 }
640
641 pub fn yank_current(&mut self) {
643 let node_info = self
645 .current_node()
646 .map(|node| (node.path.clone(), node.name.clone(), node.depth));
647
648 if let Some((path, name, depth)) = node_info {
649 if depth == 0 {
651 self.message = Some("Cannot yank root directory".to_string());
652 return;
653 }
654 self.clipboard.paths = vec![path];
655 self.clipboard.operation = ClipboardOperation::Copy;
656 self.message = Some(format!("Yanked: {name}"));
657 }
658 }
659
660 pub fn cut_current(&mut self) {
662 let node_info = self
664 .current_node()
665 .map(|node| (node.path.clone(), node.name.clone(), node.depth));
666
667 if let Some((path, name, depth)) = node_info {
668 if depth == 0 {
670 self.message = Some("Cannot cut root directory".to_string());
671 return;
672 }
673 self.clipboard.paths = vec![path];
674 self.clipboard.operation = ClipboardOperation::Cut;
675 self.message = Some(format!("Cut: {name}"));
676 }
677 }
678
679 pub fn paste(&mut self) -> io::Result<()> {
681 if self.clipboard.paths.is_empty() {
682 self.message = Some("Clipboard is empty".to_string());
683 return Ok(());
684 }
685
686 let target_dir = self
688 .get_creation_parent()
689 .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "No target directory"))?;
690
691 let mut success_count = 0;
692 let operation = self.clipboard.operation.clone();
693
694 for source_path in self.clipboard.paths.clone() {
695 let file_name = source_path
696 .file_name()
697 .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "Invalid path"))?;
698 let mut dest_path = target_dir.join(file_name);
699
700 if dest_path.exists() && dest_path != source_path {
702 let stem = dest_path
703 .file_stem()
704 .and_then(|s| s.to_str())
705 .unwrap_or("file");
706 let ext = dest_path.extension().and_then(|s| s.to_str());
707 let new_name =
708 ext.map_or_else(|| format!("{stem}_copy"), |ext| format!("{stem}_copy.{ext}"));
709 dest_path = target_dir.join(new_name);
710 }
711
712 if dest_path == source_path {
714 continue;
715 }
716
717 let result = if source_path.is_dir() {
719 copy_dir_recursive(&source_path, &dest_path)
720 } else {
721 fs::copy(&source_path, &dest_path).map(|_| ())
722 };
723
724 if result.is_ok() {
725 success_count += 1;
726 if operation == ClipboardOperation::Cut {
728 if source_path.is_dir() {
729 let _ = fs::remove_dir_all(&source_path);
730 } else {
731 let _ = fs::remove_file(&source_path);
732 }
733 }
734 }
735 }
736
737 if operation == ClipboardOperation::Cut {
739 self.clipboard.paths.clear();
740 }
741
742 self.message = Some(format!("Pasted {success_count} item(s)"));
743 self.refresh()?;
744 Ok(())
745 }
746
747 pub fn enter_visual_mode(&mut self) {
749 self.selection.active = true;
750 self.selection.anchor_index = Some(self.cursor_index);
751 self.selection.selected.clear();
752
753 if let Some(node) = self.current_node()
755 && node.depth > 0
756 {
757 self.selection.selected.insert(node.path.clone());
758 }
759 }
760
761 pub fn exit_visual_mode(&mut self) {
763 self.selection.active = false;
764 self.selection.anchor_index = None;
765 self.selection.selected.clear();
766 }
767
768 pub fn toggle_select_current(&mut self) {
770 let path = self
771 .current_node()
772 .filter(|n| n.depth > 0)
773 .map(|n| n.path.clone());
774
775 if let Some(path) = path {
776 if self.selection.selected.contains(&path) {
777 self.selection.selected.remove(&path);
778 } else {
779 self.selection.selected.insert(path);
780 }
781 }
782 }
783
784 pub fn select_all(&mut self) {
786 let paths: Vec<PathBuf> = self
788 .visible_nodes()
789 .iter()
790 .filter(|node| node.depth > 0)
791 .map(|node| node.path.clone())
792 .collect();
793
794 self.selection.active = true;
795 self.selection.selected.clear();
796 for path in paths {
797 self.selection.selected.insert(path);
798 }
799
800 let count = self.selection.selected.len();
801 self.message = Some(format!("Selected {count} item(s)"));
802 }
803
804 pub fn update_visual_selection(&mut self) {
806 if !self.selection.active {
807 return;
808 }
809
810 let Some(anchor) = self.selection.anchor_index else {
811 return;
812 };
813
814 let start = anchor.min(self.cursor_index);
815 let end = anchor.max(self.cursor_index);
816
817 let paths: Vec<PathBuf> = self
819 .visible_nodes()
820 .iter()
821 .enumerate()
822 .filter(|(i, node)| *i >= start && *i <= end && node.depth > 0)
823 .map(|(_, node)| node.path.clone())
824 .collect();
825
826 self.selection.selected.clear();
827 for path in paths {
828 self.selection.selected.insert(path);
829 }
830 }
831
832 #[must_use]
834 pub fn is_selected(&self, path: &Path) -> bool {
835 self.selection.selected.contains(path)
836 }
837
838 pub fn yank_selected(&mut self) {
840 if self.selection.selected.is_empty() {
841 self.yank_current();
842 return;
843 }
844
845 let paths: Vec<PathBuf> = self.selection.selected.iter().cloned().collect();
846 let count = paths.len();
847 self.clipboard.paths = paths;
848 self.clipboard.operation = ClipboardOperation::Copy;
849 self.message = Some(format!("Yanked {count} item(s)"));
850 self.exit_visual_mode();
851 }
852
853 pub fn cut_selected(&mut self) {
855 if self.selection.selected.is_empty() {
856 self.cut_current();
857 return;
858 }
859
860 let paths: Vec<PathBuf> = self.selection.selected.iter().cloned().collect();
861 let count = paths.len();
862 self.clipboard.paths = paths;
863 self.clipboard.operation = ClipboardOperation::Cut;
864 self.message = Some(format!("Cut {count} item(s)"));
865 self.exit_visual_mode();
866 }
867}
868
869fn copy_dir_recursive(src: &Path, dst: &Path) -> io::Result<()> {
871 fs::create_dir_all(dst)?;
872
873 for entry in fs::read_dir(src)? {
874 let entry = entry?;
875 let src_path = entry.path();
876 let dst_path = dst.join(entry.file_name());
877
878 if src_path.is_dir() {
879 copy_dir_recursive(&src_path, &dst_path)?;
880 } else {
881 fs::copy(&src_path, &dst_path)?;
882 }
883 }
884
885 Ok(())
886}
887
888#[cfg(test)]
889mod tests {
890 use {super::*, std::fs::File, tempfile::tempdir};
891
892 #[test]
893 fn test_new_state() {
894 let dir = tempdir().unwrap();
895 File::create(dir.path().join("test.txt")).unwrap();
896
897 let state = ExplorerState::new(dir.path().to_path_buf()).unwrap();
898 assert_eq!(state.cursor_index, 0);
899 assert!(!state.show_hidden);
900 }
901
902 #[test]
903 fn test_move_cursor() {
904 let dir = tempdir().unwrap();
905 File::create(dir.path().join("a.txt")).unwrap();
906 File::create(dir.path().join("b.txt")).unwrap();
907 File::create(dir.path().join("c.txt")).unwrap();
908
909 let mut state = ExplorerState::new(dir.path().to_path_buf()).unwrap();
910
911 state.move_cursor(1);
913 assert_eq!(state.cursor_index, 1);
914
915 state.move_cursor(1);
916 assert_eq!(state.cursor_index, 2);
917
918 state.move_cursor(10);
920 assert_eq!(state.cursor_index, 3); state.move_cursor(-1);
924 assert_eq!(state.cursor_index, 2);
925
926 state.move_cursor(-10);
928 assert_eq!(state.cursor_index, 0);
929 }
930
931 #[test]
932 fn test_filter() {
933 let dir = tempdir().unwrap();
934 File::create(dir.path().join("apple.txt")).unwrap();
935 File::create(dir.path().join("banana.txt")).unwrap();
936 File::create(dir.path().join("cherry.txt")).unwrap();
937
938 let mut state = ExplorerState::new(dir.path().to_path_buf()).unwrap();
939
940 assert_eq!(state.visible_nodes().len(), 4); state.set_filter("an".to_string());
945 let nodes = state.visible_nodes();
946 assert_eq!(nodes.len(), 1);
947 assert_eq!(nodes[0].name, "banana.txt");
948
949 state.clear_filter();
951 assert_eq!(state.visible_nodes().len(), 4);
952 }
953
954 #[test]
955 fn test_toggle_hidden() {
956 let dir = tempdir().unwrap();
957 File::create(dir.path().join("visible.txt")).unwrap();
958 File::create(dir.path().join(".hidden")).unwrap();
959
960 let mut state = ExplorerState::new(dir.path().to_path_buf()).unwrap();
961
962 assert_eq!(state.visible_nodes().len(), 2); state.toggle_hidden();
967 assert_eq!(state.visible_nodes().len(), 3); }
969}