reovim_plugin_explorer/
state.rs

1use std::{
2    fs, io,
3    path::{Path, PathBuf},
4};
5
6use super::{node::FileNode, tree::FileTree};
7
8/// State of the file explorer
9#[derive(Clone, Debug)]
10pub struct ExplorerState {
11    /// The file tree structure
12    pub tree: FileTree,
13    /// Index of the currently selected item in the flattened view
14    pub cursor_index: usize,
15    /// Whether to show hidden files
16    pub show_hidden: bool,
17    /// Whether to show file sizes
18    pub show_sizes: bool,
19    /// Current filter text
20    pub filter_text: String,
21    /// Width of the explorer panel
22    pub width: u16,
23    /// Scroll offset for the view
24    pub scroll_offset: usize,
25    /// Visible height of the explorer window (set during render)
26    pub visible_height: u16,
27    /// Current input mode
28    pub input_mode: ExplorerInputMode,
29    /// Current input buffer (for create/rename/filter)
30    pub input_buffer: String,
31    /// Message to display (e.g., error or confirmation prompt)
32    pub message: Option<String>,
33    /// Clipboard for copy/cut/paste operations
34    pub clipboard: ExplorerClipboard,
35    /// Multi-file selection state
36    pub selection: ExplorerSelection,
37    /// Whether the explorer is currently visible
38    pub visible: bool,
39    /// File details popup state
40    pub popup: FileDetailsPopup,
41    /// Enable file type syntax coloring
42    pub enable_colors: bool,
43    /// Tree drawing style for visual hierarchy
44    pub tree_style: TreeStyle,
45}
46
47/// Tree drawing style for visual hierarchy
48#[derive(Clone, Debug, Default, PartialEq, Eq)]
49pub enum TreeStyle {
50    /// No tree lines, just indentation with spaces
51    None,
52    /// Simple ASCII indentation with ">" character
53    Simple,
54    /// Box-drawing characters (│, ├, └) for visual hierarchy
55    #[default]
56    BoxDrawing,
57}
58
59/// Input mode for file operations
60#[derive(Clone, Debug, Default, PartialEq, Eq)]
61pub enum ExplorerInputMode {
62    /// Normal navigation mode
63    #[default]
64    None,
65    /// Creating a new file
66    CreateFile,
67    /// Creating a new directory
68    CreateDir,
69    /// Renaming current item
70    Rename,
71    /// Confirming deletion
72    ConfirmDelete,
73    /// Filtering files
74    Filter,
75}
76
77/// Clipboard operation type
78#[derive(Clone, Debug, Default, PartialEq, Eq)]
79pub enum ClipboardOperation {
80    /// Copy operation (keep original)
81    #[default]
82    Copy,
83    /// Cut operation (delete original after paste)
84    Cut,
85}
86
87/// Explorer clipboard for copy/cut/paste operations
88#[derive(Clone, Debug, Default)]
89pub struct ExplorerClipboard {
90    /// Paths in the clipboard
91    pub paths: Vec<PathBuf>,
92    /// Operation type
93    pub operation: ClipboardOperation,
94}
95
96/// Multi-file selection for explorer
97#[derive(Clone, Debug, Default)]
98pub struct ExplorerSelection {
99    /// Set of selected file paths
100    pub selected: std::collections::HashSet<PathBuf>,
101    /// Whether visual selection mode is active
102    pub active: bool,
103    /// Anchor index for visual selection (where selection started)
104    pub anchor_index: Option<usize>,
105}
106
107/// File details popup state
108#[derive(Clone, Debug, Default)]
109pub struct FileDetailsPopup {
110    /// Whether the popup is visible
111    pub visible: bool,
112    /// File/directory name
113    pub name: String,
114    /// Full path
115    pub path: String,
116    /// Type: "file", "directory", "symlink"
117    pub file_type: String,
118    /// Formatted size (for files)
119    pub size: Option<String>,
120    /// Formatted creation time
121    pub created: Option<String>,
122    /// Formatted modification time
123    pub modified: Option<String>,
124}
125
126impl ExplorerState {
127    /// Create a new explorer state from a root path
128    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, // Default height, updated on render
140            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,               // Enable colors by default
148            tree_style: TreeStyle::BoxDrawing, // Use box-drawing by default
149        })
150    }
151
152    /// Check if in input mode
153    #[must_use]
154    pub fn is_input_mode(&self) -> bool {
155        self.input_mode != ExplorerInputMode::None
156    }
157
158    /// Toggle explorer visibility
159    pub fn toggle_visibility(&mut self) {
160        self.visible = !self.visible;
161    }
162
163    /// Show the explorer
164    pub fn show(&mut self) {
165        self.visible = true;
166    }
167
168    /// Hide the explorer
169    pub fn hide(&mut self) {
170        self.visible = false;
171    }
172
173    /// Start creating a new file
174    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    /// Start creating a new directory
185    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    /// Start renaming current item
192    pub fn start_rename(&mut self) {
193        // Extract info first to avoid borrow issues
194        let info = self.current_node().map(|n| (n.name.clone(), n.depth));
195        if let Some((name, depth)) = info {
196            // Protect root directory from being renamed
197            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    /// Start delete confirmation
208    pub fn start_delete(&mut self) {
209        // Extract info first to avoid borrow issues
210        let info = self.current_node().map(|n| (n.name.clone(), n.depth));
211        if let Some((name, depth)) = info {
212            // Protect root directory from being deleted
213            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    /// Start filter mode
224    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    /// Cancel current input mode
231    pub fn cancel_input(&mut self) {
232        self.input_mode = ExplorerInputMode::None;
233        self.input_buffer.clear();
234        self.message = None;
235    }
236
237    /// Add a character to input buffer
238    pub fn input_char(&mut self, c: char) {
239        self.input_buffer.push(c);
240        // For filter mode, apply filter in real-time
241        if self.input_mode == ExplorerInputMode::Filter {
242            self.filter_text = self.input_buffer.clone();
243            self.adjust_cursor_after_filter();
244        }
245    }
246
247    /// Remove last character from input buffer
248    pub fn input_backspace(&mut self) {
249        self.input_buffer.pop();
250        // For filter mode, apply filter in real-time
251        if self.input_mode == ExplorerInputMode::Filter {
252            self.filter_text = self.input_buffer.clone();
253            self.adjust_cursor_after_filter();
254        }
255    }
256
257    /// Ensure cursor is within valid bounds
258    ///
259    /// Call this after any operation that may change the number of visible nodes.
260    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    /// Adjust cursor after filter changes
270    fn adjust_cursor_after_filter(&mut self) {
271        self.validate_cursor_bounds();
272    }
273
274    /// Confirm current input operation
275    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                // Filter is already applied, just exit input mode
293            }
294        }
295        self.input_mode = ExplorerInputMode::None;
296        self.input_buffer.clear();
297        self.message = None;
298        Ok(())
299    }
300
301    /// Get the parent directory for new file/directory creation
302    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    /// Create a new file
316    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    /// Create a new directory
329    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    /// Rename current item
342    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    /// Delete current item
358    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    /// Get all visible nodes (respecting hidden files and filter)
372    #[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        // Filter nodes by name
381        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    /// Get the currently selected node
389    #[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    /// Get the path of the currently selected node
396    #[must_use]
397    pub fn current_path(&self) -> Option<&Path> {
398        self.current_node().map(|n| n.path.as_path())
399    }
400
401    /// Move the cursor by a delta amount
402    #[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    /// Move cursor to the first item
424    pub const fn move_to_first(&mut self) {
425        self.cursor_index = 0;
426    }
427
428    /// Move cursor to the last item
429    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    /// Move cursor by a page
435    #[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    /// Toggle expand/collapse on the current directory
443    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    /// Expand the current directory
451    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    /// Collapse the current directory
459    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    /// Go to parent directory (change root to parent and position cursor on old root)
466    ///
467    /// This implements nvim-tree style navigation:
468    /// 1. Change explorer root to parent directory
469    /// 2. Position cursor on the directory we just came from
470    pub fn go_to_parent(&mut self) {
471        let current_root = self.tree.root_path().to_path_buf();
472
473        // Get parent of current root
474        let Some(parent_path) = current_root.parent() else {
475            // Already at filesystem root
476            self.message = Some("Already at root".to_string());
477            return;
478        };
479
480        // Remember the old root name to find it after changing root
481        let old_root_name = current_root.clone();
482
483        // Change root to parent directory
484        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        // Find the old root directory in the new view and position cursor on it
490        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    /// Change root to currently selected directory
500    ///
501    /// If the current selection is a directory, make it the new root.
502    /// If it's a file, use its parent directory as the new root.
503    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            // For files, use the parent directory
512            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    /// Set the filter text
524    pub fn set_filter(&mut self, text: String) {
525        self.filter_text = text;
526        self.validate_cursor_bounds();
527    }
528
529    /// Clear the filter
530    pub fn clear_filter(&mut self) {
531        self.filter_text.clear();
532    }
533
534    /// Toggle showing hidden files
535    pub fn toggle_hidden(&mut self) {
536        self.show_hidden = !self.show_hidden;
537        self.validate_cursor_bounds();
538    }
539
540    /// Toggle showing file sizes
541    pub const fn toggle_sizes(&mut self) {
542        self.show_sizes = !self.show_sizes;
543    }
544
545    /// Refresh the tree from the filesystem
546    pub fn refresh(&mut self) -> io::Result<()> {
547        self.tree.refresh()?;
548        self.validate_cursor_bounds();
549        Ok(())
550    }
551
552    /// Set a new root path
553    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    /// Set visible height (called from render)
561    pub const fn set_visible_height(&mut self, height: u16) {
562        self.visible_height = height;
563    }
564
565    /// Update scroll offset to keep cursor visible
566    pub const fn update_scroll(&mut self) {
567        let height = self.visible_height as usize;
568        if height == 0 {
569            return;
570        }
571
572        // Ensure cursor is visible
573        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    /// Show file details popup for the current node
581    pub fn show_file_details(&mut self) {
582        use super::node::{format_datetime, format_size};
583
584        // Extract node data first to avoid borrow conflict
585        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    /// Close the file details popup
624    pub fn close_popup(&mut self) {
625        self.popup.visible = false;
626    }
627
628    /// Check if popup is visible
629    #[must_use]
630    pub const fn is_popup_visible(&self) -> bool {
631        self.popup.visible
632    }
633
634    /// Sync popup with current cursor position (update content if visible)
635    pub fn sync_popup(&mut self) {
636        if self.popup.visible {
637            self.show_file_details();
638        }
639    }
640
641    /// Yank (copy) current item to clipboard
642    pub fn yank_current(&mut self) {
643        // Extract data first to avoid borrow conflicts
644        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            // Don't allow yanking root
650            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    /// Cut current item to clipboard
661    pub fn cut_current(&mut self) {
662        // Extract data first to avoid borrow conflicts
663        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            // Don't allow cutting root
669            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    /// Paste from clipboard to current directory
680    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        // Get target directory
687        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            // Handle name conflicts by appending _copy
701            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            // Skip if source and dest are the same
713            if dest_path == source_path {
714                continue;
715            }
716
717            // Perform the operation
718            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                // For cut operation, delete the source after successful copy
727                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        // Clear clipboard after cut operation
738        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    /// Enter visual selection mode
748    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        // Select current item
754        if let Some(node) = self.current_node()
755            && node.depth > 0
756        {
757            self.selection.selected.insert(node.path.clone());
758        }
759    }
760
761    /// Exit visual selection mode
762    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    /// Toggle selection of current item
769    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    /// Select all visible items
785    pub fn select_all(&mut self) {
786        // Collect paths first to avoid borrow conflict
787        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    /// Update visual selection when cursor moves
805    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        // Collect paths first to avoid borrow conflict
818        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    /// Check if a path is selected
833    #[must_use]
834    pub fn is_selected(&self, path: &Path) -> bool {
835        self.selection.selected.contains(path)
836    }
837
838    /// Yank selected items to clipboard (for multi-selection)
839    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    /// Cut selected items (for multi-selection)
854    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
869/// Recursively copy a directory and its contents
870fn 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        // Move down
912        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        // Move past end (should clamp)
919        state.move_cursor(10);
920        assert_eq!(state.cursor_index, 3); // root + 3 files - 1
921
922        // Move up
923        state.move_cursor(-1);
924        assert_eq!(state.cursor_index, 2);
925
926        // Move past start (should clamp)
927        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        // No filter - all visible
941        assert_eq!(state.visible_nodes().len(), 4); // root + 3 files
942
943        // Filter for "an"
944        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        // Clear filter
950        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        // Hidden files not shown by default
963        assert_eq!(state.visible_nodes().len(), 2); // root + visible
964
965        // Toggle to show hidden
966        state.toggle_hidden();
967        assert_eq!(state.visible_nodes().len(), 3); // root + visible + hidden
968    }
969}