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 using
145                // a natural (alphanumeric) comparison so `chapter-2`
146                // sorts before `chapter-10` (issue #2073).
147                let mut sorted_entries = entries;
148                sorted_entries.sort_by(|a, b| match (a.is_dir(), b.is_dir()) {
149                    (true, false) => std::cmp::Ordering::Less,
150                    (false, true) => std::cmp::Ordering::Greater,
151                    _ => natural_cmp(&a.name, &b.name),
152                });
153
154                // Create child nodes
155                let mut child_ids = Vec::new();
156                for entry in sorted_entries {
157                    let child_id = self.add_node(entry, Some(id));
158                    child_ids.push(child_id);
159                }
160
161                // Update parent node
162                if let Some(node) = self.get_node_mut(id) {
163                    node.children = child_ids;
164                    node.state = NodeState::Expanded;
165                }
166
167                Ok(())
168            }
169            Err(e) => {
170                // Set error state
171                if let Some(node) = self.get_node_mut(id) {
172                    node.state = NodeState::Error(e.to_string());
173                }
174                Err(e)
175            }
176        }
177    }
178
179    /// Collapse a directory node
180    ///
181    /// This removes all child nodes from memory to save space.
182    /// They will be reloaded if the directory is expanded again.
183    pub fn collapse_node(&mut self, id: NodeId) {
184        if let Some(node) = self.get_node(id) {
185            if !node.is_dir() {
186                return;
187            }
188
189            // Collect child IDs to remove
190            let children_to_remove: Vec<NodeId> = node.children.clone();
191
192            // Remove all descendants recursively
193            for child_id in children_to_remove {
194                self.remove_node_recursive(child_id);
195            }
196        }
197
198        // Update parent node state
199        if let Some(node) = self.get_node_mut(id) {
200            node.children.clear();
201            node.state = NodeState::Collapsed;
202        }
203    }
204
205    /// Toggle node expansion (expand if collapsed, collapse if expanded)
206    pub async fn toggle_node(&mut self, id: NodeId) -> io::Result<()> {
207        let node = self
208            .get_node(id)
209            .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "Node not found"))?;
210
211        if !node.is_dir() {
212            return Ok(());
213        }
214
215        if node.is_expanded() {
216            self.collapse_node(id);
217            Ok(())
218        } else {
219            self.expand_node(id).await
220        }
221    }
222
223    /// Refresh a node (re-read directory contents)
224    ///
225    /// This is useful when filesystem contents have changed.
226    pub async fn refresh_node(&mut self, id: NodeId) -> io::Result<()> {
227        // Collapse and re-expand
228        self.collapse_node(id);
229        self.expand_node(id).await
230    }
231
232    /// Re-read this directory from disk, preserving the expansion state of
233    /// every descendant that is still present afterwards.
234    ///
235    /// Implementation is deliberately simple: snapshot the paths of all
236    /// currently-expanded descendants, run a normal `refresh_node` (which
237    /// rebuilds child ids via the well-tested `expand_node` path), then
238    /// re-walk each previously-expanded path so its subtree loads again.
239    /// Descendants whose path no longer exists on disk are silently
240    /// dropped — `expand_to_path` returns None for them.
241    ///
242    /// Callers should not rely on NodeIds under `id` surviving the call:
243    /// refresh_node recycles every descendant id. Cursor / multi-selection
244    /// state should be re-resolved by path afterwards.
245    pub async fn reload_expanded_node(&mut self, id: NodeId) -> io::Result<()> {
246        let expanded_paths = self.collect_expanded_descendant_paths(id);
247        self.refresh_node(id).await?;
248        // Re-expand each previously-expanded descendant in tree order —
249        // i.e. shallowest first, so `expand_to_path` can walk through them.
250        // `expand_to_path` only expands intermediate ancestors along the
251        // way, so also call `expand_node` on the resolved target so the
252        // directory's own children load.
253        for path in expanded_paths {
254            if let Some(target_id) = self.expand_to_path(&path).await {
255                if let Err(e) = self.expand_node(target_id).await {
256                    tracing::warn!("Failed to re-expand {:?} after tree reload: {}", path, e);
257                }
258            }
259        }
260        Ok(())
261    }
262
263    /// Collect the on-disk paths of every descendant of `id` that is in
264    /// `Expanded` state. Excludes `id` itself — the caller is about to
265    /// refresh that node, which handles its own expansion.
266    fn collect_expanded_descendant_paths(&self, id: NodeId) -> Vec<PathBuf> {
267        let mut out = Vec::new();
268        if let Some(node) = self.get_node(id) {
269            for &child in &node.children {
270                self.collect_expanded_recursive(child, &mut out);
271            }
272        }
273        out
274    }
275
276    fn collect_expanded_recursive(&self, id: NodeId, out: &mut Vec<PathBuf>) {
277        if let Some(node) = self.get_node(id) {
278            if node.is_expanded() {
279                out.push(node.entry.path.clone());
280                for &child in &node.children {
281                    self.collect_expanded_recursive(child, out);
282                }
283            }
284        }
285    }
286
287    /// Get all visible nodes in tree order
288    ///
289    /// Returns a flat list of nodes that should be visible, respecting
290    /// the expansion state of parent directories.
291    pub fn get_visible_nodes(&self) -> Vec<NodeId> {
292        let mut visible = Vec::new();
293        self.collect_visible_recursive(self.root_id, &mut visible);
294        visible
295    }
296
297    /// Recursively collect visible nodes
298    fn collect_visible_recursive(&self, id: NodeId, visible: &mut Vec<NodeId>) {
299        visible.push(id);
300
301        if let Some(node) = self.get_node(id) {
302            if node.is_expanded() {
303                for &child_id in &node.children {
304                    self.collect_visible_recursive(child_id, visible);
305                }
306            }
307        }
308    }
309
310    /// Get the parent chain for a node (from root to node)
311    pub fn get_ancestors(&self, id: NodeId) -> Vec<NodeId> {
312        let mut ancestors = Vec::new();
313        let mut current = Some(id);
314
315        while let Some(node_id) = current {
316            ancestors.push(node_id);
317            current = self.get_node(node_id).and_then(|n| n.parent);
318        }
319
320        ancestors.reverse();
321        ancestors
322    }
323
324    /// Get the depth of a node (root is 0)
325    pub fn get_depth(&self, id: NodeId) -> usize {
326        self.get_ancestors(id).len() - 1
327    }
328
329    /// Find node by relative path from root
330    pub fn find_by_relative_path(&self, relative_path: &Path) -> Option<NodeId> {
331        let full_path = self.root_path.join(relative_path);
332        self.path_to_node.get(&full_path).copied()
333    }
334
335    /// Add a new node to the tree
336    fn add_node(&mut self, entry: DirEntry, parent: Option<NodeId>) -> NodeId {
337        let id = NodeId(self.next_id);
338        self.next_id += 1;
339
340        let node = TreeNode::new(id, entry.clone(), parent);
341        self.path_to_node.insert(entry.path.clone(), id);
342        self.nodes.insert(id, node);
343
344        id
345    }
346
347    /// Remove a node and all its descendants
348    fn remove_node_recursive(&mut self, id: NodeId) {
349        if let Some(node) = self.get_node(id) {
350            let children = node.children.clone();
351            let path = node.entry.path.clone();
352
353            // Remove all children first
354            for child_id in children {
355                self.remove_node_recursive(child_id);
356            }
357
358            // Remove from path mapping
359            self.path_to_node.remove(&path);
360
361            // Remove node itself
362            self.nodes.remove(&id);
363        }
364    }
365
366    /// Get number of nodes currently in memory
367    pub fn node_count(&self) -> usize {
368        self.nodes.len()
369    }
370
371    /// Expand all directories along a path and return the final node ID
372    ///
373    /// This is useful for revealing a specific file in the tree, even if its
374    /// parent directories are collapsed. All parent directories will be expanded
375    /// as needed.
376    ///
377    /// # Arguments
378    ///
379    /// * `path` - The full path to the target file or directory
380    ///
381    /// # Returns
382    ///
383    /// Returns the NodeId of the target if found, or None if:
384    /// - The path is not under the root directory
385    /// - The path doesn't exist
386    /// - There was an error expanding intermediate directories
387    ///
388    /// # Example
389    ///
390    /// ```ignore
391    /// // Expand to src/components/App.js
392    /// if let Some(node_id) = tree.expand_to_path(&project_root.join("src/components/App.js")).await {
393    ///     // All parent directories (src, src/components) are now expanded
394    ///     // node_id points to App.js
395    /// }
396    /// ```
397    pub async fn expand_to_path(&mut self, path: &Path) -> Option<NodeId> {
398        // Check if path is under root
399        let relative_path = path.strip_prefix(&self.root_path).ok()?;
400
401        // Start from root
402        let mut current_id = self.root_id;
403
404        // Walk through each component of the path
405        for component in relative_path.components() {
406            let component_str = component.as_os_str().to_str()?;
407
408            // Expand current directory if it's not already expanded
409            let node = self.get_node(current_id)?;
410            if node.is_dir() && !node.is_expanded() {
411                // Expand this directory
412                if let Err(e) = self.expand_node(current_id).await {
413                    tracing::warn!("Failed to expand node during path traversal: {}", e);
414                    return None;
415                }
416            }
417
418            // Find the child with the matching name
419            let node = self.get_node(current_id)?;
420            let child_id = node
421                .children
422                .iter()
423                .find(|&&child_id| {
424                    if let Some(child_node) = self.get_node(child_id) {
425                        child_node.entry.name == component_str
426                    } else {
427                        false
428                    }
429                })
430                .copied();
431
432            match child_id {
433                Some(id) => current_id = id,
434                None => {
435                    // Child not found - path doesn't exist
436                    tracing::warn!("Component '{}' not found in tree", component_str);
437                    return None;
438                }
439            }
440        }
441
442        Some(current_id)
443    }
444}
445
446/// Natural ordering of two filenames: ASCII-digit runs compare as
447/// integers, everything else as lowercase strings. This gives a
448/// "human" order like `chapter-2 < chapter-10` (issue #2073) without
449/// pulling in a sort crate.
450///
451/// Only ASCII digits are treated as a numeric run — letters, symbols,
452/// and non-ASCII characters fall back to lowercase string comparison.
453/// Leading zeros are ignored when comparing magnitudes (`v01 == v1`
454/// by value); ties resolve by raw width, with the shorter (unpadded)
455/// form first, matching GNU `sort -V` (`v1 < v01`).
456fn natural_cmp(a: &str, b: &str) -> std::cmp::Ordering {
457    use std::cmp::Ordering;
458    let mut ai = a.char_indices().peekable();
459    let mut bi = b.char_indices().peekable();
460    loop {
461        match (ai.peek().copied(), bi.peek().copied()) {
462            (None, None) => return Ordering::Equal,
463            (None, Some(_)) => return Ordering::Less,
464            (Some(_), None) => return Ordering::Greater,
465            (Some((_, ca)), Some((_, cb))) => {
466                if ca.is_ascii_digit() && cb.is_ascii_digit() {
467                    // Compare numeric runs by magnitude (length of the
468                    // non-zero portion, then digit-by-digit), then by
469                    // raw width so leading-zero variants are ordered
470                    // deterministically without claiming equality.
471                    let (a_start, a_end) = take_digit_run(&mut ai);
472                    let (b_start, b_end) = take_digit_run(&mut bi);
473                    let a_digits = &a[a_start..a_end];
474                    let b_digits = &b[b_start..b_end];
475                    let a_trim = a_digits.trim_start_matches('0');
476                    let b_trim = b_digits.trim_start_matches('0');
477                    match a_trim.len().cmp(&b_trim.len()) {
478                        Ordering::Equal => match a_trim.cmp(b_trim) {
479                            Ordering::Equal => {
480                                if a_digits.len() != b_digits.len() {
481                                    return a_digits.len().cmp(&b_digits.len());
482                                }
483                            }
484                            other => return other,
485                        },
486                        other => return other,
487                    }
488                } else {
489                    let (_, ca) = ai.next().unwrap();
490                    let (_, cb) = bi.next().unwrap();
491                    let la = ca.to_ascii_lowercase();
492                    let lb = cb.to_ascii_lowercase();
493                    if la != lb {
494                        return la.cmp(&lb);
495                    }
496                }
497            }
498        }
499    }
500}
501
502/// Consume a contiguous ASCII-digit run from `iter` and return its
503/// byte range in the original string. `iter` is left pointing at the
504/// first non-digit (or exhausted).
505fn take_digit_run<I>(iter: &mut std::iter::Peekable<I>) -> (usize, usize)
506where
507    I: Iterator<Item = (usize, char)>,
508{
509    let (start, _) = *iter.peek().expect("caller checked at least one digit");
510    let mut end = start;
511    while let Some(&(idx, c)) = iter.peek() {
512        if c.is_ascii_digit() {
513            end = idx + c.len_utf8();
514            iter.next();
515        } else {
516            break;
517        }
518    }
519    (start, end)
520}
521
522#[cfg(test)]
523mod tests {
524    use super::*;
525    use crate::model::filesystem::StdFileSystem;
526    use std::fs as std_fs;
527    use tempfile::TempDir;
528
529    // ── natural_cmp ──────────────────────────────────────────────────
530
531    /// Issue #2073: digit runs in filenames should compare by
532    /// magnitude so `chapter-2 < chapter-10`, not lexicographically.
533    #[test]
534    fn natural_cmp_orders_digit_runs_by_magnitude() {
535        let mut names = vec![
536            "chapter-1.md",
537            "chapter-10.md",
538            "chapter-2.md",
539            "chapter-21.md",
540            "chapter-3.md",
541        ];
542        names.sort_by(|a, b| natural_cmp(a, b));
543        assert_eq!(
544            names,
545            vec![
546                "chapter-1.md",
547                "chapter-2.md",
548                "chapter-3.md",
549                "chapter-10.md",
550                "chapter-21.md",
551            ]
552        );
553    }
554
555    /// Non-digit segments keep falling back to case-insensitive
556    /// comparison — `Foo` and `foo` compare equal, `bar` sorts before
557    /// `Foo`, mixed cases still group together rather than splitting
558    /// upper- and lowercase apart the way raw `cmp` would.
559    #[test]
560    fn natural_cmp_is_case_insensitive_for_text() {
561        use std::cmp::Ordering;
562        assert_eq!(natural_cmp("Foo.txt", "foo.txt"), Ordering::Equal);
563        assert_eq!(natural_cmp("bar.txt", "Foo.txt"), Ordering::Less);
564    }
565
566    /// Leading zeros do not change magnitude but still break ties —
567    /// the shorter (unpadded) form wins, matching GNU `sort -V` so a
568    /// sorted listing is deterministic regardless of input order.
569    #[test]
570    fn natural_cmp_leading_zeros_break_ties_after_magnitude() {
571        use std::cmp::Ordering;
572        assert_eq!(natural_cmp("v1", "v01"), Ordering::Less);
573        assert_eq!(natural_cmp("v01", "v1"), Ordering::Greater);
574        // But magnitude wins over width — `v002` still sorts before `v10`.
575        assert_eq!(natural_cmp("v002.txt", "v10.txt"), Ordering::Less);
576    }
577
578    /// Mixed digit + text runs alternate cleanly: shared prefix,
579    /// numeric run by magnitude, shared suffix.
580    #[test]
581    fn natural_cmp_handles_mixed_runs() {
582        use std::cmp::Ordering;
583        assert_eq!(natural_cmp("img2a.png", "img10a.png"), Ordering::Less);
584        assert_eq!(natural_cmp("img2b.png", "img2a.png"), Ordering::Greater);
585        assert_eq!(natural_cmp("img.png", "img2.png"), Ordering::Less);
586    }
587
588    async fn create_test_tree() -> (TempDir, FileTree) {
589        let temp_dir = TempDir::new().unwrap();
590        let temp_path = temp_dir.path();
591
592        // Create test structure:
593        // /
594        // ├── dir1/
595        // │   ├── file1.txt
596        // │   └── file2.txt
597        // ├── dir2/
598        // │   └── subdir/
599        // │       └── file3.txt
600        // └── file4.txt
601
602        std_fs::create_dir(temp_path.join("dir1")).unwrap();
603        std_fs::write(temp_path.join("dir1/file1.txt"), "content1").unwrap();
604        std_fs::write(temp_path.join("dir1/file2.txt"), "content2").unwrap();
605
606        std_fs::create_dir(temp_path.join("dir2")).unwrap();
607        std_fs::create_dir(temp_path.join("dir2/subdir")).unwrap();
608        std_fs::write(temp_path.join("dir2/subdir/file3.txt"), "content3").unwrap();
609
610        std_fs::write(temp_path.join("file4.txt"), "content4").unwrap();
611
612        let backend = Arc::new(StdFileSystem);
613        let manager = Arc::new(FsManager::new(backend));
614        let tree = FileTree::new(temp_path.to_path_buf(), manager)
615            .await
616            .unwrap();
617
618        (temp_dir, tree)
619    }
620
621    #[tokio::test]
622    async fn test_tree_creation() {
623        let (_temp_dir, tree) = create_test_tree().await;
624
625        assert_eq!(tree.node_count(), 1); // Only root initially
626        let root = tree.get_node(tree.root_id()).unwrap();
627        assert!(root.is_collapsed());
628        assert_eq!(root.children.len(), 0);
629    }
630
631    #[tokio::test]
632    async fn test_expand_root() {
633        let (_temp_dir, mut tree) = create_test_tree().await;
634
635        tree.expand_node(tree.root_id()).await.unwrap();
636
637        let root = tree.get_node(tree.root_id()).unwrap();
638        assert!(root.is_expanded());
639        assert_eq!(root.children.len(), 3); // dir1, dir2, file4.txt
640
641        // Should be 4 nodes: root + 3 children
642        assert_eq!(tree.node_count(), 4);
643    }
644
645    #[tokio::test]
646    async fn test_expand_nested() {
647        let (_temp_dir, mut tree) = create_test_tree().await;
648
649        // Expand root
650        tree.expand_node(tree.root_id()).await.unwrap();
651
652        // Find dir1 and expand it
653        let root = tree.get_node(tree.root_id()).unwrap();
654        let dir1_id = root.children[0]; // dir1 (directories come first)
655
656        tree.expand_node(dir1_id).await.unwrap();
657
658        let dir1 = tree.get_node(dir1_id).unwrap();
659        assert!(dir1.is_expanded());
660        assert_eq!(dir1.children.len(), 2); // file1.txt, file2.txt
661
662        // Total nodes: root + 3 children + 2 grandchildren = 6
663        assert_eq!(tree.node_count(), 6);
664    }
665
666    #[tokio::test]
667    async fn test_collapse_node() {
668        let (_temp_dir, mut tree) = create_test_tree().await;
669
670        // Expand root and dir1
671        tree.expand_node(tree.root_id()).await.unwrap();
672        let root = tree.get_node(tree.root_id()).unwrap();
673        let dir1_id = root.children[0];
674        tree.expand_node(dir1_id).await.unwrap();
675
676        assert_eq!(tree.node_count(), 6);
677
678        // Collapse dir1
679        tree.collapse_node(dir1_id);
680
681        let dir1 = tree.get_node(dir1_id).unwrap();
682        assert!(dir1.is_collapsed());
683        assert_eq!(dir1.children.len(), 0);
684
685        // Should remove the 2 child nodes
686        assert_eq!(tree.node_count(), 4);
687    }
688
689    #[tokio::test]
690    async fn test_toggle_node() {
691        let (_temp_dir, mut tree) = create_test_tree().await;
692
693        tree.expand_node(tree.root_id()).await.unwrap();
694        let root = tree.get_node(tree.root_id()).unwrap();
695        let dir1_id = root.children[0];
696
697        // Toggle to expand
698        tree.toggle_node(dir1_id).await.unwrap();
699        assert!(tree.get_node(dir1_id).unwrap().is_expanded());
700
701        // Toggle to collapse
702        tree.toggle_node(dir1_id).await.unwrap();
703        assert!(tree.get_node(dir1_id).unwrap().is_collapsed());
704    }
705
706    #[tokio::test]
707    async fn test_get_visible_nodes() {
708        let (_temp_dir, mut tree) = create_test_tree().await;
709
710        // Initially only root is visible
711        let visible = tree.get_visible_nodes();
712        assert_eq!(visible.len(), 1);
713
714        // Expand root
715        tree.expand_node(tree.root_id()).await.unwrap();
716        let visible = tree.get_visible_nodes();
717        assert_eq!(visible.len(), 4); // root + 3 children
718
719        // Expand dir1
720        let root = tree.get_node(tree.root_id()).unwrap();
721        let dir1_id = root.children[0];
722        tree.expand_node(dir1_id).await.unwrap();
723
724        let visible = tree.get_visible_nodes();
725        assert_eq!(visible.len(), 6); // root + 3 children + 2 grandchildren
726    }
727
728    #[tokio::test]
729    async fn test_get_ancestors() {
730        let (_temp_dir, mut tree) = create_test_tree().await;
731
732        tree.expand_node(tree.root_id()).await.unwrap();
733        let root = tree.get_node(tree.root_id()).unwrap();
734        let dir1_id = root.children[0];
735        tree.expand_node(dir1_id).await.unwrap();
736
737        let dir1 = tree.get_node(dir1_id).unwrap();
738        let file1_id = dir1.children[0];
739
740        let ancestors = tree.get_ancestors(file1_id);
741        assert_eq!(ancestors.len(), 3); // root -> dir1 -> file1
742        assert_eq!(ancestors[0], tree.root_id());
743        assert_eq!(ancestors[1], dir1_id);
744        assert_eq!(ancestors[2], file1_id);
745    }
746
747    #[tokio::test]
748    async fn test_get_depth() {
749        let (_temp_dir, mut tree) = create_test_tree().await;
750
751        tree.expand_node(tree.root_id()).await.unwrap();
752        let root = tree.get_node(tree.root_id()).unwrap();
753        let dir1_id = root.children[0];
754        tree.expand_node(dir1_id).await.unwrap();
755
756        assert_eq!(tree.get_depth(tree.root_id()), 0);
757        assert_eq!(tree.get_depth(dir1_id), 1);
758
759        let dir1 = tree.get_node(dir1_id).unwrap();
760        let file1_id = dir1.children[0];
761        assert_eq!(tree.get_depth(file1_id), 2);
762    }
763
764    #[tokio::test]
765    async fn test_sorted_entries() {
766        let (_temp_dir, mut tree) = create_test_tree().await;
767
768        tree.expand_node(tree.root_id()).await.unwrap();
769
770        let root = tree.get_node(tree.root_id()).unwrap();
771        let children: Vec<_> = root
772            .children
773            .iter()
774            .map(|&id| tree.get_node(id).unwrap())
775            .collect();
776
777        // Directories should come first
778        assert!(children[0].is_dir());
779        assert!(children[1].is_dir());
780        assert!(children[2].is_file());
781
782        // Directories should be sorted by name
783        assert_eq!(children[0].entry.name, "dir1");
784        assert_eq!(children[1].entry.name, "dir2");
785    }
786
787    #[tokio::test]
788    async fn test_refresh_node() {
789        let temp_dir = TempDir::new().unwrap();
790        let temp_path = temp_dir.path();
791
792        std_fs::create_dir(temp_path.join("dir1")).unwrap();
793        std_fs::write(temp_path.join("dir1/file1.txt"), "content").unwrap();
794
795        let backend = Arc::new(StdFileSystem);
796        let manager = Arc::new(FsManager::new(backend));
797        let mut tree = FileTree::new(temp_path.to_path_buf(), manager)
798            .await
799            .unwrap();
800
801        // Expand root and dir1
802        tree.expand_node(tree.root_id()).await.unwrap();
803        let root = tree.get_node(tree.root_id()).unwrap();
804        let dir1_id = root.children[0];
805        tree.expand_node(dir1_id).await.unwrap();
806
807        // Initially 1 file in dir1
808        let dir1 = tree.get_node(dir1_id).unwrap();
809        assert_eq!(dir1.children.len(), 1);
810
811        // Add another file
812        std_fs::write(temp_path.join("dir1/file2.txt"), "content2").unwrap();
813
814        // Refresh dir1
815        tree.refresh_node(dir1_id).await.unwrap();
816
817        // Should now have 2 files
818        let dir1 = tree.get_node(dir1_id).unwrap();
819        assert_eq!(dir1.children.len(), 2);
820    }
821
822    #[tokio::test]
823    async fn test_find_by_relative_path() {
824        let (_temp_dir, mut tree) = create_test_tree().await;
825
826        tree.expand_node(tree.root_id()).await.unwrap();
827        let root = tree.get_node(tree.root_id()).unwrap();
828        let dir1_id = root.children[0];
829
830        let found_id = tree.find_by_relative_path(Path::new("dir1"));
831        assert_eq!(found_id, Some(dir1_id));
832
833        let not_found = tree.find_by_relative_path(Path::new("nonexistent"));
834        assert_eq!(not_found, None);
835    }
836
837    #[tokio::test]
838    async fn test_expand_to_path() {
839        let (_temp_dir, mut tree) = create_test_tree().await;
840        let root_path = tree.root_path().to_path_buf();
841
842        // Initially tree is collapsed
843        assert_eq!(tree.node_count(), 1);
844
845        // Expand to a deeply nested file
846        let target_path = root_path.join("dir2/subdir/file3.txt");
847        let node_id = tree.expand_to_path(&target_path).await;
848
849        assert!(node_id.is_some(), "Should find the nested file");
850
851        // All parent directories should now be expanded
852        let root = tree.get_node(tree.root_id()).unwrap();
853        assert!(root.is_expanded(), "Root should be expanded");
854
855        // Find dir2
856        let dir2_node = root
857            .children
858            .iter()
859            .find_map(|&id| {
860                let node = tree.get_node(id)?;
861                if node.entry.name == "dir2" {
862                    Some(node)
863                } else {
864                    None
865                }
866            })
867            .expect("dir2 should exist");
868
869        assert!(dir2_node.is_expanded(), "dir2 should be expanded");
870
871        // Find subdir
872        let subdir_node = dir2_node
873            .children
874            .iter()
875            .find_map(|&id| {
876                let node = tree.get_node(id)?;
877                if node.entry.name == "subdir" {
878                    Some(node)
879                } else {
880                    None
881                }
882            })
883            .expect("subdir should exist");
884
885        assert!(subdir_node.is_expanded(), "subdir should be expanded");
886
887        // Verify the target file is found
888        let target_node = tree.get_node(node_id.unwrap()).unwrap();
889        assert_eq!(target_node.entry.name, "file3.txt");
890        assert!(target_node.is_file());
891    }
892
893    #[tokio::test]
894    async fn test_expand_to_path_not_under_root() {
895        let (_temp_dir, mut tree) = create_test_tree().await;
896
897        // Try to expand to a path not under root
898        let outside_path = PathBuf::from("/tmp/somefile.txt");
899        let result = tree.expand_to_path(&outside_path).await;
900
901        assert!(
902            result.is_none(),
903            "Should return None for paths outside root"
904        );
905    }
906
907    #[tokio::test]
908    async fn test_expand_to_path_nonexistent() {
909        let (_temp_dir, mut tree) = create_test_tree().await;
910        let root_path = tree.root_path().to_path_buf();
911
912        // Try to expand to a nonexistent file
913        let nonexistent_path = root_path.join("dir1/nonexistent.txt");
914        let result = tree.expand_to_path(&nonexistent_path).await;
915
916        assert!(result.is_none(), "Should return None for nonexistent paths");
917    }
918
919    // End-to-end observable behavior for `reload_expanded_node` —
920    // preserved expansion state, visibility of newly-appeared files,
921    // freshness of rendered metadata — is exercised at the e2e harness
922    // level in `tests/e2e/explorer_bugs.rs`. No unit tests here poke at
923    // `self.nodes` / `self.path_to_node` directly.
924}