Skip to main content

fresh/view/file_tree/
view.rs

1use super::ignore::IgnorePatterns;
2use super::node::NodeId;
3use super::search::FileExplorerSearch;
4use super::tree::FileTree;
5use crate::input::fuzzy::FuzzyMatch;
6use crate::model::filesystem::DirEntry;
7use std::collections::HashMap;
8use std::path::PathBuf;
9
10/// View state for file tree navigation and filtering
11#[derive(Debug)]
12pub struct FileTreeView {
13    /// The underlying tree model
14    tree: FileTree,
15    /// Currently selected node
16    selected_node: Option<NodeId>,
17    /// Scroll offset (index into visible nodes)
18    scroll_offset: usize,
19    /// Sort mode for entries
20    sort_mode: SortMode,
21    /// Ignore patterns for filtering
22    ignore_patterns: IgnorePatterns,
23    /// Last known viewport height (for scrolling calculations)
24    pub(crate) viewport_height: usize,
25    /// Search state for quick navigation
26    search: FileExplorerSearch,
27}
28
29/// Sort mode for file tree entries
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum SortMode {
32    /// Sort by name alphabetically
33    Name,
34    /// Sort by type (directories first, then files)
35    Type,
36    /// Sort by modification time (newest first)
37    Modified,
38}
39
40impl FileTreeView {
41    /// Create a new file tree view
42    pub fn new(tree: FileTree) -> Self {
43        let root_id = tree.root_id();
44        Self {
45            tree,
46            selected_node: Some(root_id),
47            scroll_offset: 0,
48            sort_mode: SortMode::Type,
49            ignore_patterns: IgnorePatterns::new(),
50            viewport_height: 10, // Default, will be updated during rendering
51            search: FileExplorerSearch::new(),
52        }
53    }
54
55    /// Get visible nodes filtered by ignore patterns (hidden files, gitignored, etc.)
56    ///
57    /// Walks the expanded tree and skips ignored nodes along with their entire
58    /// subtree. The root node is never filtered out.
59    fn filtered_visible_nodes(&self) -> Vec<NodeId> {
60        let mut result = Vec::new();
61        self.collect_filtered_visible(self.tree.root_id(), &mut result);
62        result
63    }
64
65    /// Recursively collect visible nodes, skipping ignored subtrees.
66    fn collect_filtered_visible(&self, id: NodeId, result: &mut Vec<NodeId>) {
67        let is_root = id == self.tree.root_id();
68        if !is_root && !self.is_node_visible(id) {
69            return;
70        }
71
72        result.push(id);
73
74        if let Some(node) = self.tree.get_node(id) {
75            if node.is_expanded() {
76                for &child_id in &node.children {
77                    self.collect_filtered_visible(child_id, result);
78                }
79            }
80        }
81    }
82
83    /// Set the viewport height (should be called during rendering)
84    pub fn set_viewport_height(&mut self, height: usize) {
85        self.viewport_height = height;
86    }
87
88    /// Get the underlying tree
89    pub fn tree(&self) -> &FileTree {
90        &self.tree
91    }
92
93    /// Get mutable reference to the underlying tree
94    pub fn tree_mut(&mut self) -> &mut FileTree {
95        &mut self.tree
96    }
97
98    /// Get currently visible nodes with their indent levels
99    ///
100    /// Returns a list of (NodeId, indent_level) tuples for rendering.
101    pub fn get_display_nodes(&self) -> Vec<(NodeId, usize)> {
102        let visible = self.filtered_visible_nodes();
103        visible
104            .into_iter()
105            .map(|id| {
106                let depth = self.tree.get_depth(id);
107                (id, depth)
108            })
109            .collect()
110    }
111
112    /// Get the currently selected node ID
113    pub fn get_selected(&self) -> Option<NodeId> {
114        self.selected_node
115    }
116
117    /// Set the selected node
118    pub fn set_selected(&mut self, node_id: Option<NodeId>) {
119        self.selected_node = node_id;
120    }
121
122    /// Select the next visible node
123    pub fn select_next(&mut self) {
124        let visible = self.filtered_visible_nodes();
125        if visible.is_empty() {
126            return;
127        }
128
129        if let Some(current) = self.selected_node {
130            if let Some(pos) = visible.iter().position(|&id| id == current) {
131                if pos + 1 < visible.len() {
132                    self.selected_node = Some(visible[pos + 1]);
133                }
134            }
135        } else {
136            self.selected_node = Some(visible[0]);
137        }
138    }
139
140    /// Select the previous visible node
141    pub fn select_prev(&mut self) {
142        let visible = self.filtered_visible_nodes();
143        if visible.is_empty() {
144            return;
145        }
146
147        if let Some(current) = self.selected_node {
148            if let Some(pos) = visible.iter().position(|&id| id == current) {
149                if pos > 0 {
150                    self.selected_node = Some(visible[pos - 1]);
151                }
152            }
153        } else {
154            self.selected_node = Some(visible[0]);
155        }
156    }
157
158    /// Move selection up by a page (viewport height)
159    pub fn select_page_up(&mut self) {
160        if self.viewport_height == 0 {
161            return;
162        }
163
164        let visible = self.filtered_visible_nodes();
165        if visible.is_empty() {
166            return;
167        }
168
169        if let Some(current) = self.selected_node {
170            if let Some(pos) = visible.iter().position(|&id| id == current) {
171                let new_pos = pos.saturating_sub(self.viewport_height);
172                self.selected_node = Some(visible[new_pos]);
173            }
174        } else {
175            self.selected_node = Some(visible[0]);
176        }
177    }
178
179    /// Move selection down by a page (viewport height)
180    pub fn select_page_down(&mut self) {
181        if self.viewport_height == 0 {
182            return;
183        }
184
185        let visible = self.filtered_visible_nodes();
186        if visible.is_empty() {
187            return;
188        }
189
190        if let Some(current) = self.selected_node {
191            if let Some(pos) = visible.iter().position(|&id| id == current) {
192                let new_pos = (pos + self.viewport_height).min(visible.len() - 1);
193                self.selected_node = Some(visible[new_pos]);
194            }
195        } else {
196            self.selected_node = Some(visible[0]);
197        }
198    }
199
200    /// Update scroll offset to ensure symmetric scrolling behavior
201    ///
202    /// This should be called after navigation to implement symmetric scrolling:
203    /// - When moving down, cursor moves to bottom of viewport before scrolling
204    /// - When moving up, cursor moves to top of viewport before scrolling
205    ///
206    /// Uses the stored viewport_height which is updated during rendering.
207    pub fn update_scroll_for_selection(&mut self) {
208        if self.viewport_height == 0 {
209            return;
210        }
211
212        if let Some(selected) = self.selected_node {
213            let visible = self.filtered_visible_nodes();
214            if let Some(pos) = visible.iter().position(|&id| id == selected) {
215                // Only scroll if cursor goes PAST the viewport edges
216                // This implements symmetric scrolling behavior
217
218                // If selection is above the visible area, scroll up
219                if pos < self.scroll_offset {
220                    self.scroll_offset = pos;
221                }
222                // If selection is below the visible area, scroll down
223                else if pos >= self.scroll_offset + self.viewport_height {
224                    self.scroll_offset = pos - self.viewport_height + 1;
225                }
226                // Otherwise, cursor is within viewport - don't scroll
227            }
228        }
229    }
230
231    /// Select the first visible node
232    pub fn select_first(&mut self) {
233        let visible = self.filtered_visible_nodes();
234        if !visible.is_empty() {
235            self.selected_node = Some(visible[0]);
236        }
237    }
238
239    /// Select the last visible node
240    pub fn select_last(&mut self) {
241        let visible = self.filtered_visible_nodes();
242        if !visible.is_empty() {
243            self.selected_node = Some(*visible.last().unwrap());
244        }
245    }
246
247    /// Select the parent of the currently selected node
248    pub fn select_parent(&mut self) {
249        if let Some(current) = self.selected_node {
250            if let Some(node) = self.tree.get_node(current) {
251                if let Some(parent_id) = node.parent {
252                    self.selected_node = Some(parent_id);
253                }
254            }
255        }
256    }
257
258    /// Get the scroll offset
259    pub fn get_scroll_offset(&self) -> usize {
260        self.scroll_offset
261    }
262
263    /// Set the scroll offset
264    pub fn set_scroll_offset(&mut self, offset: usize) {
265        self.scroll_offset = offset;
266    }
267
268    /// Ensure the selected node is visible within the viewport
269    ///
270    /// Adjusts scroll offset if necessary to keep the selected node visible.
271    ///
272    /// # Arguments
273    ///
274    /// * `viewport_height` - Number of visible lines in the viewport
275    pub fn ensure_visible(&mut self, viewport_height: usize) {
276        if viewport_height == 0 {
277            return;
278        }
279
280        if let Some(selected) = self.selected_node {
281            let visible = self.filtered_visible_nodes();
282            if let Some(pos) = visible.iter().position(|&id| id == selected) {
283                // If selection is above viewport, scroll up
284                if pos < self.scroll_offset {
285                    self.scroll_offset = pos;
286                }
287                // If selection is below viewport, scroll down
288                else if pos >= self.scroll_offset + viewport_height {
289                    self.scroll_offset = pos - viewport_height + 1;
290                }
291            }
292        }
293    }
294
295    /// Get the sort mode
296    pub fn get_sort_mode(&self) -> SortMode {
297        self.sort_mode
298    }
299
300    /// Set the sort mode
301    pub fn set_sort_mode(&mut self, mode: SortMode) {
302        self.sort_mode = mode;
303        // TODO: Re-sort children when sort mode changes
304    }
305
306    /// Get selected node entry (convenience method)
307    pub fn get_selected_entry(&self) -> Option<&DirEntry> {
308        self.selected_node
309            .and_then(|id| self.tree.get_node(id))
310            .map(|node| &node.entry)
311    }
312
313    /// Navigate to a specific path if it exists in the tree
314    pub fn navigate_to_path(&mut self, path: &std::path::Path) {
315        if let Some(node) = self.tree.get_node_by_path(path) {
316            self.selected_node = Some(node.id);
317        }
318    }
319
320    /// Get the index of the selected node in the visible list
321    pub fn get_selected_index(&self) -> Option<usize> {
322        if let Some(selected) = self.selected_node {
323            let visible = self.filtered_visible_nodes();
324            visible.iter().position(|&id| id == selected)
325        } else {
326            None
327        }
328    }
329
330    /// Get visible node at index (accounting for scroll offset)
331    pub fn get_node_at_index(&self, index: usize) -> Option<NodeId> {
332        let visible = self.filtered_visible_nodes();
333        visible.get(index).copied()
334    }
335
336    /// Get the number of visible nodes
337    pub fn visible_count(&self) -> usize {
338        self.filtered_visible_nodes().len()
339    }
340
341    /// Get reference to ignore patterns
342    pub fn ignore_patterns(&self) -> &IgnorePatterns {
343        &self.ignore_patterns
344    }
345
346    /// Get mutable reference to ignore patterns
347    pub fn ignore_patterns_mut(&mut self) -> &mut IgnorePatterns {
348        &mut self.ignore_patterns
349    }
350
351    /// Toggle showing hidden files
352    pub fn toggle_show_hidden(&mut self) {
353        self.ignore_patterns.toggle_show_hidden();
354    }
355
356    /// Toggle showing gitignored files
357    pub fn toggle_show_gitignored(&mut self) {
358        self.ignore_patterns.toggle_show_gitignored();
359    }
360
361    /// Check if a node should be visible (not filtered by ignore patterns)
362    pub fn is_node_visible(&self, node_id: NodeId) -> bool {
363        if let Some(node) = self.tree.get_node(node_id) {
364            !self
365                .ignore_patterns
366                .is_ignored(&node.entry.path, node.is_dir())
367        } else {
368            false
369        }
370    }
371
372    /// Load .gitignore for a directory
373    ///
374    /// This should be called when expanding a directory to load its .gitignore
375    pub fn load_gitignore_for_dir(&mut self, dir_path: &std::path::Path) -> std::io::Result<()> {
376        self.ignore_patterns.load_gitignore(dir_path)
377    }
378
379    /// Expand all parent directories and select the given file path
380    ///
381    /// This is useful for revealing a specific file in the tree when switching
382    /// focus to the file explorer. All parent directories will be expanded as needed,
383    /// and the file will be selected.
384    ///
385    /// # Arguments
386    ///
387    /// * `path` - The full path to the file to reveal and select
388    ///
389    /// # Returns
390    ///
391    /// Returns true if the file was successfully expanded and selected, false otherwise.
392    /// This will return false if:
393    /// - The path is not under the root directory
394    /// - The path doesn't exist
395    /// - There was an error expanding intermediate directories
396    pub async fn expand_and_select_file(&mut self, path: &std::path::Path) -> bool {
397        if let Some(node_id) = self.tree.expand_to_path(path).await {
398            self.selected_node = Some(node_id);
399            true
400        } else {
401            false
402        }
403    }
404
405    /// Collect symlink mappings from expanded symlink directories.
406    ///
407    /// Returns a HashMap where keys are symlink paths and values are their canonical targets.
408    /// This is used to create decoration aliases so files under symlinked directories
409    /// can show their git status correctly.
410    pub fn collect_symlink_mappings(&self) -> HashMap<PathBuf, PathBuf> {
411        let mut mappings = HashMap::new();
412
413        for node_id in self.filtered_visible_nodes() {
414            if let Some(node) = self.tree.get_node(node_id) {
415                // Only process expanded symlink directories
416                if node.entry.is_symlink() && node.is_dir() && node.is_expanded() {
417                    // Canonicalize the symlink to get the target
418                    if let Ok(canonical) = node.entry.path.canonicalize() {
419                        if canonical != node.entry.path {
420                            mappings.insert(node.entry.path.clone(), canonical);
421                        }
422                    }
423                }
424            }
425        }
426
427        mappings
428    }
429
430    // ==================== Search Methods ====================
431
432    /// Get the current search query
433    pub fn search_query(&self) -> &str {
434        self.search.query()
435    }
436
437    /// Check if search is active
438    pub fn is_search_active(&self) -> bool {
439        self.search.is_active()
440    }
441
442    /// Add a character to the search query and jump to first match
443    pub fn search_push_char(&mut self, c: char) {
444        self.search.push_char(c);
445        self.jump_to_first_match();
446    }
447
448    /// Remove the last character from the search query
449    pub fn search_pop_char(&mut self) {
450        self.search.pop_char();
451        if self.search.is_active() {
452            self.jump_to_first_match();
453        }
454    }
455
456    /// Clear the search query
457    pub fn search_clear(&mut self) {
458        self.search.clear();
459    }
460
461    /// Get nodes that match the current search query
462    fn get_matching_nodes(&self) -> Vec<NodeId> {
463        if !self.search.is_active() {
464            return self.filtered_visible_nodes();
465        }
466
467        self.filtered_visible_nodes()
468            .into_iter()
469            .filter(|&id| {
470                if let Some(node) = self.tree.get_node(id) {
471                    self.search.matches(&node.entry.name)
472                } else {
473                    false
474                }
475            })
476            .collect()
477    }
478
479    /// Jump to the first matching node
480    fn jump_to_first_match(&mut self) {
481        let matching = self.get_matching_nodes();
482        if let Some(&first) = matching.first() {
483            self.selected_node = Some(first);
484            self.update_scroll_for_selection();
485        }
486    }
487
488    /// Select the next matching node (when search is active)
489    pub fn select_next_match(&mut self) {
490        if !self.search.is_active() {
491            self.select_next();
492            return;
493        }
494
495        let matching = self.get_matching_nodes();
496        if matching.is_empty() {
497            return;
498        }
499
500        if let Some(current) = self.selected_node {
501            if let Some(pos) = matching.iter().position(|&id| id == current) {
502                // Move to next match (wrap around)
503                let next_pos = (pos + 1) % matching.len();
504                self.selected_node = Some(matching[next_pos]);
505            } else {
506                // Current not in matches, select first match
507                self.selected_node = Some(matching[0]);
508            }
509        } else {
510            self.selected_node = Some(matching[0]);
511        }
512    }
513
514    /// Select the previous matching node (when search is active)
515    pub fn select_prev_match(&mut self) {
516        if !self.search.is_active() {
517            self.select_prev();
518            return;
519        }
520
521        let matching = self.get_matching_nodes();
522        if matching.is_empty() {
523            return;
524        }
525
526        if let Some(current) = self.selected_node {
527            if let Some(pos) = matching.iter().position(|&id| id == current) {
528                // Move to previous match (wrap around)
529                let prev_pos = if pos == 0 {
530                    matching.len() - 1
531                } else {
532                    pos - 1
533                };
534                self.selected_node = Some(matching[prev_pos]);
535            } else {
536                // Current not in matches, select last match
537                self.selected_node = Some(*matching.last().unwrap());
538            }
539        } else {
540            self.selected_node = Some(*matching.last().unwrap());
541        }
542    }
543
544    /// Get match result for a node's name (for highlighting)
545    pub fn get_match_for_node(&self, node_id: NodeId) -> Option<FuzzyMatch> {
546        if !self.search.is_active() {
547            return None;
548        }
549
550        self.tree
551            .get_node(node_id)
552            .and_then(|node| self.search.match_name(&node.entry.name))
553    }
554
555    /// Check if a node matches the current search
556    pub fn node_matches_search(&self, node_id: NodeId) -> bool {
557        if !self.search.is_active() {
558            return true;
559        }
560
561        self.tree
562            .get_node(node_id)
563            .map(|node| self.search.matches(&node.entry.name))
564            .unwrap_or(false)
565    }
566}
567
568#[cfg(test)]
569mod tests {
570    use super::*;
571    use crate::model::filesystem::StdFileSystem;
572    use crate::services::fs::FsManager;
573    use std::fs as std_fs;
574    use std::sync::Arc;
575    use tempfile::TempDir;
576
577    async fn create_test_view() -> (TempDir, FileTreeView) {
578        let temp_dir = TempDir::new().unwrap();
579        let temp_path = temp_dir.path();
580
581        // Create test structure
582        std_fs::create_dir(temp_path.join("dir1")).unwrap();
583        std_fs::write(temp_path.join("dir1/file1.txt"), "content1").unwrap();
584        std_fs::write(temp_path.join("dir1/file2.txt"), "content2").unwrap();
585        std_fs::create_dir(temp_path.join("dir2")).unwrap();
586        std_fs::write(temp_path.join("file3.txt"), "content3").unwrap();
587
588        let backend = Arc::new(StdFileSystem);
589        let manager = Arc::new(FsManager::new(backend));
590        let tree = FileTree::new(temp_path.to_path_buf(), manager)
591            .await
592            .unwrap();
593        let view = FileTreeView::new(tree);
594
595        (temp_dir, view)
596    }
597
598    #[tokio::test]
599    async fn test_view_creation() {
600        let (_temp_dir, view) = create_test_view().await;
601
602        assert!(view.get_selected().is_some());
603        assert_eq!(view.get_scroll_offset(), 0);
604        assert_eq!(view.get_sort_mode(), SortMode::Type);
605    }
606
607    #[tokio::test]
608    async fn test_get_display_nodes() {
609        let (_temp_dir, mut view) = create_test_view().await;
610
611        // Initially only root
612        let display = view.get_display_nodes();
613        assert_eq!(display.len(), 1);
614        assert_eq!(display[0].1, 0); // Root has depth 0
615
616        // Expand root
617        let root_id = view.tree().root_id();
618        view.tree_mut().expand_node(root_id).await.unwrap();
619
620        let display = view.get_display_nodes();
621        assert_eq!(display.len(), 4); // root + 3 children
622
623        // Check depths
624        assert_eq!(display[0].1, 0); // root
625        assert_eq!(display[1].1, 1); // child
626        assert_eq!(display[2].1, 1); // child
627        assert_eq!(display[3].1, 1); // child
628    }
629
630    #[tokio::test]
631    async fn test_navigation() {
632        let (_temp_dir, mut view) = create_test_view().await;
633
634        let root_id = view.tree().root_id();
635        view.tree_mut().expand_node(root_id).await.unwrap();
636
637        let root_id = view.tree().root_id();
638        assert_eq!(view.get_selected(), Some(root_id));
639
640        // Select next
641        view.select_next();
642        assert_ne!(view.get_selected(), Some(root_id));
643
644        // Select prev
645        view.select_prev();
646        assert_eq!(view.get_selected(), Some(root_id));
647
648        // Select last
649        view.select_last();
650        let visible = view.tree().get_visible_nodes();
651        assert_eq!(view.get_selected(), Some(*visible.last().unwrap()));
652
653        // Select first
654        view.select_first();
655        assert_eq!(view.get_selected(), Some(root_id));
656    }
657
658    #[tokio::test]
659    async fn test_select_parent() {
660        let (_temp_dir, mut view) = create_test_view().await;
661
662        let root_id = view.tree().root_id();
663        view.tree_mut().expand_node(root_id).await.unwrap();
664
665        // Select first child
666        view.select_next();
667        let child_id = view.get_selected().unwrap();
668        assert_ne!(child_id, root_id);
669
670        // Select parent
671        view.select_parent();
672        assert_eq!(view.get_selected(), Some(root_id));
673    }
674
675    #[tokio::test]
676    async fn test_ensure_visible() {
677        let (_temp_dir, mut view) = create_test_view().await;
678
679        let root_id = view.tree().root_id();
680        view.tree_mut().expand_node(root_id).await.unwrap();
681
682        let viewport_height = 2;
683
684        // Select last item
685        view.select_last();
686        view.ensure_visible(viewport_height);
687
688        // Scroll offset should be adjusted
689        let selected_index = view.get_selected_index().unwrap();
690        assert!(selected_index >= view.get_scroll_offset());
691        assert!(selected_index < view.get_scroll_offset() + viewport_height);
692
693        // Select first item
694        view.select_first();
695        view.ensure_visible(viewport_height);
696
697        // Scroll offset should be 0
698        assert_eq!(view.get_scroll_offset(), 0);
699    }
700
701    #[tokio::test]
702    async fn test_get_selected_entry() {
703        let (_temp_dir, view) = create_test_view().await;
704
705        let entry = view.get_selected_entry();
706        assert!(entry.is_some());
707        assert!(entry.unwrap().is_dir());
708    }
709
710    #[tokio::test]
711    async fn test_navigate_to_path() {
712        let (_temp_dir, mut view) = create_test_view().await;
713
714        let root_id = view.tree().root_id();
715        view.tree_mut().expand_node(root_id).await.unwrap();
716
717        let dir1_path = view.tree().root_path().join("dir1");
718        view.navigate_to_path(&dir1_path);
719
720        let selected_entry = view.get_selected_entry().unwrap();
721        assert_eq!(selected_entry.name, "dir1");
722    }
723
724    #[tokio::test]
725    async fn test_get_selected_index() {
726        let (_temp_dir, mut view) = create_test_view().await;
727
728        let root_id = view.tree().root_id();
729        view.tree_mut().expand_node(root_id).await.unwrap();
730
731        // Root is at index 0
732        assert_eq!(view.get_selected_index(), Some(0));
733
734        // Move to next
735        view.select_next();
736        assert_eq!(view.get_selected_index(), Some(1));
737
738        // Move to last
739        view.select_last();
740        let visible_count = view.visible_count();
741        assert_eq!(view.get_selected_index(), Some(visible_count - 1));
742    }
743
744    #[tokio::test]
745    async fn test_visible_count() {
746        let (_temp_dir, mut view) = create_test_view().await;
747
748        // Initially only root
749        assert_eq!(view.visible_count(), 1);
750
751        // After expanding root
752        let root_id = view.tree().root_id();
753        view.tree_mut().expand_node(root_id).await.unwrap();
754        assert_eq!(view.visible_count(), 4); // root + 3 children
755    }
756
757    #[tokio::test]
758    async fn test_sort_mode() {
759        let (_temp_dir, mut view) = create_test_view().await;
760
761        assert_eq!(view.get_sort_mode(), SortMode::Type);
762
763        view.set_sort_mode(SortMode::Name);
764        assert_eq!(view.get_sort_mode(), SortMode::Name);
765
766        view.set_sort_mode(SortMode::Modified);
767        assert_eq!(view.get_sort_mode(), SortMode::Modified);
768    }
769}