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