Skip to main content

fresh/view/file_tree/
tree.rs

1use super::node::{NodeId, NodeState, TreeNode};
2use crate::model::filesystem::DirEntry;
3use crate::services::fs::FsManager;
4use std::collections::HashMap;
5use std::io;
6use std::path::{Path, PathBuf};
7use std::sync::Arc;
8
9/// File tree with lazy loading support
10///
11/// The tree starts with just the root node. Directories are only read
12/// when explicitly expanded via `expand_node()`. This makes the tree
13/// efficient even for very large directory structures.
14#[derive(Debug)]
15pub struct FileTree {
16    /// Root directory path
17    root_path: PathBuf,
18    /// All nodes indexed by ID
19    nodes: HashMap<NodeId, TreeNode>,
20    /// Path to node ID mapping for quick lookups
21    path_to_node: HashMap<PathBuf, NodeId>,
22    /// Root node ID
23    root_id: NodeId,
24    /// Next node ID to assign
25    next_id: usize,
26    /// Filesystem manager for async operations
27    fs_manager: Arc<FsManager>,
28}
29
30impl FileTree {
31    /// Create a new file tree rooted at the given path
32    ///
33    /// # Errors
34    ///
35    /// Returns an error if the root path doesn't exist or isn't a directory.
36    pub async fn new(root_path: PathBuf, fs_manager: Arc<FsManager>) -> io::Result<Self> {
37        // Verify root path exists and is a directory
38        if !fs_manager.exists(&root_path).await {
39            return Err(io::Error::new(
40                io::ErrorKind::NotFound,
41                format!("Path does not exist: {:?}", root_path),
42            ));
43        }
44
45        if !fs_manager.is_dir(&root_path).await? {
46            return Err(io::Error::new(
47                io::ErrorKind::InvalidInput,
48                format!("Path is not a directory: {:?}", root_path),
49            ));
50        }
51
52        // Get root entry
53        let root_entry = fs_manager.get_entry(&root_path).await?;
54
55        // Create root node
56        let root_id = NodeId(0);
57        let root_node = TreeNode::new(root_id, root_entry.clone(), None);
58
59        let mut nodes = HashMap::new();
60        nodes.insert(root_id, root_node);
61
62        let mut path_to_node = HashMap::new();
63        path_to_node.insert(root_path.clone(), root_id);
64
65        Ok(Self {
66            root_path,
67            nodes,
68            path_to_node,
69            root_id,
70            next_id: 1,
71            fs_manager,
72        })
73    }
74
75    /// Get the root node ID
76    pub fn root_id(&self) -> NodeId {
77        self.root_id
78    }
79
80    /// Get the root path
81    pub fn root_path(&self) -> &Path {
82        &self.root_path
83    }
84
85    /// Get a node by ID
86    pub fn get_node(&self, id: NodeId) -> Option<&TreeNode> {
87        self.nodes.get(&id)
88    }
89
90    /// Get a mutable reference to a node by ID
91    fn get_node_mut(&mut self, id: NodeId) -> Option<&mut TreeNode> {
92        self.nodes.get_mut(&id)
93    }
94
95    /// Get a node by path
96    pub fn get_node_by_path(&self, path: &Path) -> Option<&TreeNode> {
97        self.path_to_node
98            .get(path)
99            .and_then(|id| self.get_node(*id))
100    }
101
102    /// Get all nodes
103    pub fn all_nodes(&self) -> impl Iterator<Item = &TreeNode> {
104        self.nodes.values()
105    }
106
107    /// Expand a directory node (load its children)
108    ///
109    /// This is an async operation that reads the directory contents and creates
110    /// child nodes. If the directory is already expanded, this does nothing.
111    ///
112    /// # Errors
113    ///
114    /// Returns an error if the directory cannot be read.
115    pub async fn expand_node(&mut self, id: NodeId) -> io::Result<()> {
116        // Check if node exists and is a directory
117        let node = self
118            .get_node(id)
119            .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "Node not found"))?;
120
121        if !node.is_dir() {
122            return Err(io::Error::new(
123                io::ErrorKind::InvalidInput,
124                "Cannot expand a file node",
125            ));
126        }
127
128        // If already expanded, do nothing
129        if node.is_expanded() {
130            return Ok(());
131        }
132
133        // Set state to loading
134        if let Some(node) = self.get_node_mut(id) {
135            node.state = NodeState::Loading;
136        }
137
138        // Read directory contents with metadata (for file sizes)
139        let path = self.get_node(id).unwrap().entry.path.clone();
140        let result = self.fs_manager.list_dir_with_metadata(path.clone()).await;
141
142        match result {
143            Ok(entries) => {
144                // Sort entries: directories first, then by name
145                let mut sorted_entries = entries;
146                sorted_entries.sort_by(|a, b| match (a.is_dir(), b.is_dir()) {
147                    (true, false) => std::cmp::Ordering::Less,
148                    (false, true) => std::cmp::Ordering::Greater,
149                    _ => a.name.to_lowercase().cmp(&b.name.to_lowercase()),
150                });
151
152                // Create child nodes
153                let mut child_ids = Vec::new();
154                for entry in sorted_entries {
155                    let child_id = self.add_node(entry, Some(id));
156                    child_ids.push(child_id);
157                }
158
159                // Update parent node
160                if let Some(node) = self.get_node_mut(id) {
161                    node.children = child_ids;
162                    node.state = NodeState::Expanded;
163                }
164
165                Ok(())
166            }
167            Err(e) => {
168                // Set error state
169                if let Some(node) = self.get_node_mut(id) {
170                    node.state = NodeState::Error(e.to_string());
171                }
172                Err(e)
173            }
174        }
175    }
176
177    /// Collapse a directory node
178    ///
179    /// This removes all child nodes from memory to save space.
180    /// They will be reloaded if the directory is expanded again.
181    pub fn collapse_node(&mut self, id: NodeId) {
182        if let Some(node) = self.get_node(id) {
183            if !node.is_dir() {
184                return;
185            }
186
187            // Collect child IDs to remove
188            let children_to_remove: Vec<NodeId> = node.children.clone();
189
190            // Remove all descendants recursively
191            for child_id in children_to_remove {
192                self.remove_node_recursive(child_id);
193            }
194        }
195
196        // Update parent node state
197        if let Some(node) = self.get_node_mut(id) {
198            node.children.clear();
199            node.state = NodeState::Collapsed;
200        }
201    }
202
203    /// Toggle node expansion (expand if collapsed, collapse if expanded)
204    pub async fn toggle_node(&mut self, id: NodeId) -> io::Result<()> {
205        let node = self
206            .get_node(id)
207            .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "Node not found"))?;
208
209        if !node.is_dir() {
210            return Ok(());
211        }
212
213        if node.is_expanded() {
214            self.collapse_node(id);
215            Ok(())
216        } else {
217            self.expand_node(id).await
218        }
219    }
220
221    /// Refresh a node (re-read directory contents)
222    ///
223    /// This is useful when filesystem contents have changed.
224    pub async fn refresh_node(&mut self, id: NodeId) -> io::Result<()> {
225        // Collapse and re-expand
226        self.collapse_node(id);
227        self.expand_node(id).await
228    }
229
230    /// Re-read this directory from disk, preserving the expansion state of
231    /// every descendant that is still present afterwards.
232    ///
233    /// Implementation is deliberately simple: snapshot the paths of all
234    /// currently-expanded descendants, run a normal `refresh_node` (which
235    /// rebuilds child ids via the well-tested `expand_node` path), then
236    /// re-walk each previously-expanded path so its subtree loads again.
237    /// Descendants whose path no longer exists on disk are silently
238    /// dropped — `expand_to_path` returns None for them.
239    ///
240    /// Callers should not rely on NodeIds under `id` surviving the call:
241    /// refresh_node recycles every descendant id. Cursor / multi-selection
242    /// state should be re-resolved by path afterwards.
243    pub async fn reload_expanded_node(&mut self, id: NodeId) -> io::Result<()> {
244        let expanded_paths = self.collect_expanded_descendant_paths(id);
245        self.refresh_node(id).await?;
246        // Re-expand each previously-expanded descendant in tree order —
247        // i.e. shallowest first, so `expand_to_path` can walk through them.
248        // `expand_to_path` only expands intermediate ancestors along the
249        // way, so also call `expand_node` on the resolved target so the
250        // directory's own children load.
251        for path in expanded_paths {
252            if let Some(target_id) = self.expand_to_path(&path).await {
253                if let Err(e) = self.expand_node(target_id).await {
254                    tracing::warn!("Failed to re-expand {:?} after tree reload: {}", path, e);
255                }
256            }
257        }
258        Ok(())
259    }
260
261    /// Collect the on-disk paths of every descendant of `id` that is in
262    /// `Expanded` state. Excludes `id` itself — the caller is about to
263    /// refresh that node, which handles its own expansion.
264    fn collect_expanded_descendant_paths(&self, id: NodeId) -> Vec<PathBuf> {
265        let mut out = Vec::new();
266        if let Some(node) = self.get_node(id) {
267            for &child in &node.children {
268                self.collect_expanded_recursive(child, &mut out);
269            }
270        }
271        out
272    }
273
274    fn collect_expanded_recursive(&self, id: NodeId, out: &mut Vec<PathBuf>) {
275        if let Some(node) = self.get_node(id) {
276            if node.is_expanded() {
277                out.push(node.entry.path.clone());
278                for &child in &node.children {
279                    self.collect_expanded_recursive(child, out);
280                }
281            }
282        }
283    }
284
285    /// Get all visible nodes in tree order
286    ///
287    /// Returns a flat list of nodes that should be visible, respecting
288    /// the expansion state of parent directories.
289    pub fn get_visible_nodes(&self) -> Vec<NodeId> {
290        let mut visible = Vec::new();
291        self.collect_visible_recursive(self.root_id, &mut visible);
292        visible
293    }
294
295    /// Recursively collect visible nodes
296    fn collect_visible_recursive(&self, id: NodeId, visible: &mut Vec<NodeId>) {
297        visible.push(id);
298
299        if let Some(node) = self.get_node(id) {
300            if node.is_expanded() {
301                for &child_id in &node.children {
302                    self.collect_visible_recursive(child_id, visible);
303                }
304            }
305        }
306    }
307
308    /// Get the parent chain for a node (from root to node)
309    pub fn get_ancestors(&self, id: NodeId) -> Vec<NodeId> {
310        let mut ancestors = Vec::new();
311        let mut current = Some(id);
312
313        while let Some(node_id) = current {
314            ancestors.push(node_id);
315            current = self.get_node(node_id).and_then(|n| n.parent);
316        }
317
318        ancestors.reverse();
319        ancestors
320    }
321
322    /// Get the depth of a node (root is 0)
323    pub fn get_depth(&self, id: NodeId) -> usize {
324        self.get_ancestors(id).len() - 1
325    }
326
327    /// Find node by relative path from root
328    pub fn find_by_relative_path(&self, relative_path: &Path) -> Option<NodeId> {
329        let full_path = self.root_path.join(relative_path);
330        self.path_to_node.get(&full_path).copied()
331    }
332
333    /// Add a new node to the tree
334    fn add_node(&mut self, entry: DirEntry, parent: Option<NodeId>) -> NodeId {
335        let id = NodeId(self.next_id);
336        self.next_id += 1;
337
338        let node = TreeNode::new(id, entry.clone(), parent);
339        self.path_to_node.insert(entry.path.clone(), id);
340        self.nodes.insert(id, node);
341
342        id
343    }
344
345    /// Remove a node and all its descendants
346    fn remove_node_recursive(&mut self, id: NodeId) {
347        if let Some(node) = self.get_node(id) {
348            let children = node.children.clone();
349            let path = node.entry.path.clone();
350
351            // Remove all children first
352            for child_id in children {
353                self.remove_node_recursive(child_id);
354            }
355
356            // Remove from path mapping
357            self.path_to_node.remove(&path);
358
359            // Remove node itself
360            self.nodes.remove(&id);
361        }
362    }
363
364    /// Get number of nodes currently in memory
365    pub fn node_count(&self) -> usize {
366        self.nodes.len()
367    }
368
369    /// Expand all directories along a path and return the final node ID
370    ///
371    /// This is useful for revealing a specific file in the tree, even if its
372    /// parent directories are collapsed. All parent directories will be expanded
373    /// as needed.
374    ///
375    /// # Arguments
376    ///
377    /// * `path` - The full path to the target file or directory
378    ///
379    /// # Returns
380    ///
381    /// Returns the NodeId of the target if found, or None if:
382    /// - The path is not under the root directory
383    /// - The path doesn't exist
384    /// - There was an error expanding intermediate directories
385    ///
386    /// # Example
387    ///
388    /// ```ignore
389    /// // Expand to src/components/App.js
390    /// if let Some(node_id) = tree.expand_to_path(&project_root.join("src/components/App.js")).await {
391    ///     // All parent directories (src, src/components) are now expanded
392    ///     // node_id points to App.js
393    /// }
394    /// ```
395    pub async fn expand_to_path(&mut self, path: &Path) -> Option<NodeId> {
396        // Check if path is under root
397        let relative_path = path.strip_prefix(&self.root_path).ok()?;
398
399        // Start from root
400        let mut current_id = self.root_id;
401
402        // Walk through each component of the path
403        for component in relative_path.components() {
404            let component_str = component.as_os_str().to_str()?;
405
406            // Expand current directory if it's not already expanded
407            let node = self.get_node(current_id)?;
408            if node.is_dir() && !node.is_expanded() {
409                // Expand this directory
410                if let Err(e) = self.expand_node(current_id).await {
411                    tracing::warn!("Failed to expand node during path traversal: {}", e);
412                    return None;
413                }
414            }
415
416            // Find the child with the matching name
417            let node = self.get_node(current_id)?;
418            let child_id = node
419                .children
420                .iter()
421                .find(|&&child_id| {
422                    if let Some(child_node) = self.get_node(child_id) {
423                        child_node.entry.name == component_str
424                    } else {
425                        false
426                    }
427                })
428                .copied();
429
430            match child_id {
431                Some(id) => current_id = id,
432                None => {
433                    // Child not found - path doesn't exist
434                    tracing::warn!("Component '{}' not found in tree", component_str);
435                    return None;
436                }
437            }
438        }
439
440        Some(current_id)
441    }
442}
443
444#[cfg(test)]
445mod tests {
446    use super::*;
447    use crate::model::filesystem::StdFileSystem;
448    use std::fs as std_fs;
449    use tempfile::TempDir;
450
451    async fn create_test_tree() -> (TempDir, FileTree) {
452        let temp_dir = TempDir::new().unwrap();
453        let temp_path = temp_dir.path();
454
455        // Create test structure:
456        // /
457        // ├── dir1/
458        // │   ├── file1.txt
459        // │   └── file2.txt
460        // ├── dir2/
461        // │   └── subdir/
462        // │       └── file3.txt
463        // └── file4.txt
464
465        std_fs::create_dir(temp_path.join("dir1")).unwrap();
466        std_fs::write(temp_path.join("dir1/file1.txt"), "content1").unwrap();
467        std_fs::write(temp_path.join("dir1/file2.txt"), "content2").unwrap();
468
469        std_fs::create_dir(temp_path.join("dir2")).unwrap();
470        std_fs::create_dir(temp_path.join("dir2/subdir")).unwrap();
471        std_fs::write(temp_path.join("dir2/subdir/file3.txt"), "content3").unwrap();
472
473        std_fs::write(temp_path.join("file4.txt"), "content4").unwrap();
474
475        let backend = Arc::new(StdFileSystem);
476        let manager = Arc::new(FsManager::new(backend));
477        let tree = FileTree::new(temp_path.to_path_buf(), manager)
478            .await
479            .unwrap();
480
481        (temp_dir, tree)
482    }
483
484    #[tokio::test]
485    async fn test_tree_creation() {
486        let (_temp_dir, tree) = create_test_tree().await;
487
488        assert_eq!(tree.node_count(), 1); // Only root initially
489        let root = tree.get_node(tree.root_id()).unwrap();
490        assert!(root.is_collapsed());
491        assert_eq!(root.children.len(), 0);
492    }
493
494    #[tokio::test]
495    async fn test_expand_root() {
496        let (_temp_dir, mut tree) = create_test_tree().await;
497
498        tree.expand_node(tree.root_id()).await.unwrap();
499
500        let root = tree.get_node(tree.root_id()).unwrap();
501        assert!(root.is_expanded());
502        assert_eq!(root.children.len(), 3); // dir1, dir2, file4.txt
503
504        // Should be 4 nodes: root + 3 children
505        assert_eq!(tree.node_count(), 4);
506    }
507
508    #[tokio::test]
509    async fn test_expand_nested() {
510        let (_temp_dir, mut tree) = create_test_tree().await;
511
512        // Expand root
513        tree.expand_node(tree.root_id()).await.unwrap();
514
515        // Find dir1 and expand it
516        let root = tree.get_node(tree.root_id()).unwrap();
517        let dir1_id = root.children[0]; // dir1 (directories come first)
518
519        tree.expand_node(dir1_id).await.unwrap();
520
521        let dir1 = tree.get_node(dir1_id).unwrap();
522        assert!(dir1.is_expanded());
523        assert_eq!(dir1.children.len(), 2); // file1.txt, file2.txt
524
525        // Total nodes: root + 3 children + 2 grandchildren = 6
526        assert_eq!(tree.node_count(), 6);
527    }
528
529    #[tokio::test]
530    async fn test_collapse_node() {
531        let (_temp_dir, mut tree) = create_test_tree().await;
532
533        // Expand root and dir1
534        tree.expand_node(tree.root_id()).await.unwrap();
535        let root = tree.get_node(tree.root_id()).unwrap();
536        let dir1_id = root.children[0];
537        tree.expand_node(dir1_id).await.unwrap();
538
539        assert_eq!(tree.node_count(), 6);
540
541        // Collapse dir1
542        tree.collapse_node(dir1_id);
543
544        let dir1 = tree.get_node(dir1_id).unwrap();
545        assert!(dir1.is_collapsed());
546        assert_eq!(dir1.children.len(), 0);
547
548        // Should remove the 2 child nodes
549        assert_eq!(tree.node_count(), 4);
550    }
551
552    #[tokio::test]
553    async fn test_toggle_node() {
554        let (_temp_dir, mut tree) = create_test_tree().await;
555
556        tree.expand_node(tree.root_id()).await.unwrap();
557        let root = tree.get_node(tree.root_id()).unwrap();
558        let dir1_id = root.children[0];
559
560        // Toggle to expand
561        tree.toggle_node(dir1_id).await.unwrap();
562        assert!(tree.get_node(dir1_id).unwrap().is_expanded());
563
564        // Toggle to collapse
565        tree.toggle_node(dir1_id).await.unwrap();
566        assert!(tree.get_node(dir1_id).unwrap().is_collapsed());
567    }
568
569    #[tokio::test]
570    async fn test_get_visible_nodes() {
571        let (_temp_dir, mut tree) = create_test_tree().await;
572
573        // Initially only root is visible
574        let visible = tree.get_visible_nodes();
575        assert_eq!(visible.len(), 1);
576
577        // Expand root
578        tree.expand_node(tree.root_id()).await.unwrap();
579        let visible = tree.get_visible_nodes();
580        assert_eq!(visible.len(), 4); // root + 3 children
581
582        // Expand dir1
583        let root = tree.get_node(tree.root_id()).unwrap();
584        let dir1_id = root.children[0];
585        tree.expand_node(dir1_id).await.unwrap();
586
587        let visible = tree.get_visible_nodes();
588        assert_eq!(visible.len(), 6); // root + 3 children + 2 grandchildren
589    }
590
591    #[tokio::test]
592    async fn test_get_ancestors() {
593        let (_temp_dir, mut tree) = create_test_tree().await;
594
595        tree.expand_node(tree.root_id()).await.unwrap();
596        let root = tree.get_node(tree.root_id()).unwrap();
597        let dir1_id = root.children[0];
598        tree.expand_node(dir1_id).await.unwrap();
599
600        let dir1 = tree.get_node(dir1_id).unwrap();
601        let file1_id = dir1.children[0];
602
603        let ancestors = tree.get_ancestors(file1_id);
604        assert_eq!(ancestors.len(), 3); // root -> dir1 -> file1
605        assert_eq!(ancestors[0], tree.root_id());
606        assert_eq!(ancestors[1], dir1_id);
607        assert_eq!(ancestors[2], file1_id);
608    }
609
610    #[tokio::test]
611    async fn test_get_depth() {
612        let (_temp_dir, mut tree) = create_test_tree().await;
613
614        tree.expand_node(tree.root_id()).await.unwrap();
615        let root = tree.get_node(tree.root_id()).unwrap();
616        let dir1_id = root.children[0];
617        tree.expand_node(dir1_id).await.unwrap();
618
619        assert_eq!(tree.get_depth(tree.root_id()), 0);
620        assert_eq!(tree.get_depth(dir1_id), 1);
621
622        let dir1 = tree.get_node(dir1_id).unwrap();
623        let file1_id = dir1.children[0];
624        assert_eq!(tree.get_depth(file1_id), 2);
625    }
626
627    #[tokio::test]
628    async fn test_sorted_entries() {
629        let (_temp_dir, mut tree) = create_test_tree().await;
630
631        tree.expand_node(tree.root_id()).await.unwrap();
632
633        let root = tree.get_node(tree.root_id()).unwrap();
634        let children: Vec<_> = root
635            .children
636            .iter()
637            .map(|&id| tree.get_node(id).unwrap())
638            .collect();
639
640        // Directories should come first
641        assert!(children[0].is_dir());
642        assert!(children[1].is_dir());
643        assert!(children[2].is_file());
644
645        // Directories should be sorted by name
646        assert_eq!(children[0].entry.name, "dir1");
647        assert_eq!(children[1].entry.name, "dir2");
648    }
649
650    #[tokio::test]
651    async fn test_refresh_node() {
652        let temp_dir = TempDir::new().unwrap();
653        let temp_path = temp_dir.path();
654
655        std_fs::create_dir(temp_path.join("dir1")).unwrap();
656        std_fs::write(temp_path.join("dir1/file1.txt"), "content").unwrap();
657
658        let backend = Arc::new(StdFileSystem);
659        let manager = Arc::new(FsManager::new(backend));
660        let mut tree = FileTree::new(temp_path.to_path_buf(), manager)
661            .await
662            .unwrap();
663
664        // Expand root and dir1
665        tree.expand_node(tree.root_id()).await.unwrap();
666        let root = tree.get_node(tree.root_id()).unwrap();
667        let dir1_id = root.children[0];
668        tree.expand_node(dir1_id).await.unwrap();
669
670        // Initially 1 file in dir1
671        let dir1 = tree.get_node(dir1_id).unwrap();
672        assert_eq!(dir1.children.len(), 1);
673
674        // Add another file
675        std_fs::write(temp_path.join("dir1/file2.txt"), "content2").unwrap();
676
677        // Refresh dir1
678        tree.refresh_node(dir1_id).await.unwrap();
679
680        // Should now have 2 files
681        let dir1 = tree.get_node(dir1_id).unwrap();
682        assert_eq!(dir1.children.len(), 2);
683    }
684
685    #[tokio::test]
686    async fn test_find_by_relative_path() {
687        let (_temp_dir, mut tree) = create_test_tree().await;
688
689        tree.expand_node(tree.root_id()).await.unwrap();
690        let root = tree.get_node(tree.root_id()).unwrap();
691        let dir1_id = root.children[0];
692
693        let found_id = tree.find_by_relative_path(Path::new("dir1"));
694        assert_eq!(found_id, Some(dir1_id));
695
696        let not_found = tree.find_by_relative_path(Path::new("nonexistent"));
697        assert_eq!(not_found, None);
698    }
699
700    #[tokio::test]
701    async fn test_expand_to_path() {
702        let (_temp_dir, mut tree) = create_test_tree().await;
703        let root_path = tree.root_path().to_path_buf();
704
705        // Initially tree is collapsed
706        assert_eq!(tree.node_count(), 1);
707
708        // Expand to a deeply nested file
709        let target_path = root_path.join("dir2/subdir/file3.txt");
710        let node_id = tree.expand_to_path(&target_path).await;
711
712        assert!(node_id.is_some(), "Should find the nested file");
713
714        // All parent directories should now be expanded
715        let root = tree.get_node(tree.root_id()).unwrap();
716        assert!(root.is_expanded(), "Root should be expanded");
717
718        // Find dir2
719        let dir2_node = root
720            .children
721            .iter()
722            .find_map(|&id| {
723                let node = tree.get_node(id)?;
724                if node.entry.name == "dir2" {
725                    Some(node)
726                } else {
727                    None
728                }
729            })
730            .expect("dir2 should exist");
731
732        assert!(dir2_node.is_expanded(), "dir2 should be expanded");
733
734        // Find subdir
735        let subdir_node = dir2_node
736            .children
737            .iter()
738            .find_map(|&id| {
739                let node = tree.get_node(id)?;
740                if node.entry.name == "subdir" {
741                    Some(node)
742                } else {
743                    None
744                }
745            })
746            .expect("subdir should exist");
747
748        assert!(subdir_node.is_expanded(), "subdir should be expanded");
749
750        // Verify the target file is found
751        let target_node = tree.get_node(node_id.unwrap()).unwrap();
752        assert_eq!(target_node.entry.name, "file3.txt");
753        assert!(target_node.is_file());
754    }
755
756    #[tokio::test]
757    async fn test_expand_to_path_not_under_root() {
758        let (_temp_dir, mut tree) = create_test_tree().await;
759
760        // Try to expand to a path not under root
761        let outside_path = PathBuf::from("/tmp/somefile.txt");
762        let result = tree.expand_to_path(&outside_path).await;
763
764        assert!(
765            result.is_none(),
766            "Should return None for paths outside root"
767        );
768    }
769
770    #[tokio::test]
771    async fn test_expand_to_path_nonexistent() {
772        let (_temp_dir, mut tree) = create_test_tree().await;
773        let root_path = tree.root_path().to_path_buf();
774
775        // Try to expand to a nonexistent file
776        let nonexistent_path = root_path.join("dir1/nonexistent.txt");
777        let result = tree.expand_to_path(&nonexistent_path).await;
778
779        assert!(result.is_none(), "Should return None for nonexistent paths");
780    }
781
782    // End-to-end observable behavior for `reload_expanded_node` —
783    // preserved expansion state, visibility of newly-appeared files,
784    // freshness of rendered metadata — is exercised at the e2e harness
785    // level in `tests/e2e/explorer_bugs.rs`. No unit tests here poke at
786    // `self.nodes` / `self.path_to_node` directly.
787}