Skip to main content

termesh_filesystem/
tree.rs

1//! The lazy file-explorer tree (ADR-0005 §2).
2//!
3//! Pure data structure and pure logic: no I/O, no threads. The worker performs the
4//! actual `read_dir`; this module decides *what* to read, absorbs the result, and
5//! flattens the expanded portion into rows for the renderer.
6//!
7//! Nodes are an append-only arena keyed by [`NodeId`]. Identity is the id, never the
8//! path (ARCHITECTURE.md §7.3) — which is precisely what lets a rename storm re-read a
9//! directory without losing the user's selection or their expanded subtrees.
10
11use std::collections::HashMap;
12use std::ffi::OsString;
13use std::path::{Path, PathBuf};
14
15use termesh_core::NodeId;
16
17use crate::service::{DirEntryInfo, EntryKind, FsError};
18
19/// How much of a directory's contents we currently hold.
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub enum ChildState {
22    /// Never expanded. Costs nothing — this is what makes a monorepo root openable.
23    Unloaded,
24    /// A read is in flight on the worker thread.
25    Loading,
26    Loaded(Vec<NodeId>),
27    /// The read failed; rendered inline on the node rather than aborting the tree.
28    Error(FsError),
29}
30
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct Node {
33    pub id: NodeId,
34    pub parent: Option<NodeId>,
35    pub name: OsString,
36    pub path: PathBuf,
37    pub kind: EntryKind,
38    pub expanded: bool,
39    pub children: ChildState,
40    /// Tombstone: the entry vanished from disk. Kept so ids are never reused, and
41    /// filtered out of every traversal.
42    alive: bool,
43}
44
45impl Node {
46    /// Only real directories can hold children. Symlinks are shown but not traversed
47    /// through, so they never expand (ADR-0005 §6).
48    pub fn is_expandable(&self) -> bool {
49        self.kind == EntryKind::Dir
50    }
51}
52
53/// One flattened, renderable line of the visible tree.
54#[derive(Debug, Clone, PartialEq, Eq)]
55pub struct Row {
56    pub id: NodeId,
57    /// Nesting level; the root is 0.
58    pub depth: usize,
59    pub name: String,
60    pub kind: EntryKind,
61    pub expanded: bool,
62    pub is_expandable: bool,
63    /// Set when this node's expansion failed, for rendering the reason inline.
64    pub error: Option<String>,
65    pub loading: bool,
66}
67
68/// The explorer tree: structure, selection, and navigation. All pure logic.
69#[derive(Debug, Clone)]
70pub struct FileTree {
71    nodes: Vec<Node>,
72    root: NodeId,
73    selected: NodeId,
74}
75
76impl FileTree {
77    /// Build a tree containing just the root directory, collapsed and unloaded.
78    pub fn new(root_path: impl Into<PathBuf>, display_name: impl Into<OsString>) -> Self {
79        let root_path = root_path.into();
80        let root = NodeId::new(0);
81        let node = Node {
82            id: root,
83            parent: None,
84            name: display_name.into(),
85            path: root_path,
86            kind: EntryKind::Dir,
87            expanded: false,
88            children: ChildState::Unloaded,
89            alive: true,
90        };
91        Self { nodes: vec![node], root, selected: root }
92    }
93
94    pub fn root(&self) -> NodeId {
95        self.root
96    }
97
98    pub fn selected(&self) -> NodeId {
99        self.selected
100    }
101
102    pub fn node(&self, id: NodeId) -> Option<&Node> {
103        self.nodes.get(id.0 as usize).filter(|n| n.alive)
104    }
105
106    fn node_mut(&mut self, id: NodeId) -> Option<&mut Node> {
107        self.nodes.get_mut(id.0 as usize).filter(|n| n.alive)
108    }
109
110    pub fn path_of(&self, id: NodeId) -> Option<&Path> {
111        self.node(id).map(|n| n.path.as_path())
112    }
113
114    /// Mark a directory as awaiting a read and hand back the path the worker should
115    /// list. Returns `None` when nothing needs reading — not a directory, already
116    /// loaded, or a read is already in flight — so callers never issue duplicate work.
117    #[must_use]
118    pub fn begin_load(&mut self, id: NodeId) -> Option<PathBuf> {
119        let node = self.node_mut(id)?;
120        if node.kind != EntryKind::Dir {
121            return None;
122        }
123        match node.children {
124            ChildState::Unloaded | ChildState::Error(_) => {
125                node.children = ChildState::Loading;
126                Some(node.path.clone())
127            }
128            ChildState::Loading | ChildState::Loaded(_) => None,
129        }
130    }
131
132    /// Expand a directory. Returns a path if the contents still need to be read.
133    #[must_use]
134    pub fn expand(&mut self, id: NodeId) -> Option<PathBuf> {
135        let node = self.node_mut(id)?;
136        if node.kind != EntryKind::Dir {
137            return None;
138        }
139        node.expanded = true;
140        self.begin_load(id)
141    }
142
143    pub fn collapse(&mut self, id: NodeId) {
144        if let Some(node) = self.node_mut(id) {
145            node.expanded = false;
146        }
147    }
148
149    /// Expand if collapsed, collapse if expanded. Returns a path needing a read.
150    #[must_use]
151    pub fn toggle(&mut self, id: NodeId) -> Option<PathBuf> {
152        match self.node(id) {
153            Some(n) if n.is_expandable() && n.expanded => {
154                self.collapse(id);
155                None
156            }
157            Some(n) if n.is_expandable() => self.expand(id),
158            _ => None,
159        }
160    }
161
162    /// Record a failed directory read against its node, leaving siblings untouched.
163    pub fn set_error(&mut self, id: NodeId, error: FsError) {
164        if let Some(node) = self.node_mut(id) {
165            node.children = ChildState::Error(error);
166        }
167    }
168
169    /// Absorb a directory listing, **reconciling** against whatever is already there.
170    ///
171    /// Entries matched by name keep their `NodeId`, their expanded flag, and their
172    /// already-loaded descendants. Vanished entries are tombstoned. This is what makes
173    /// a watch-triggered re-read non-destructive to the user's view (ADR-0005 §5) —
174    /// re-reading a level is far simpler than patching the tree from event deltas, and
175    /// this reconciliation is what makes that affordable.
176    pub fn set_children(&mut self, id: NodeId, entries: Vec<DirEntryInfo>) {
177        let Some(parent) = self.node(id) else { return };
178
179        // Index the survivors by name so matching is O(n) rather than quadratic.
180        let existing: HashMap<OsString, NodeId> = match &parent.children {
181            ChildState::Loaded(ids) => ids
182                .iter()
183                .filter_map(|&cid| self.node(cid).map(|n| (n.name.clone(), cid)))
184                .collect(),
185            _ => HashMap::new(),
186        };
187
188        let mut new_children = Vec::with_capacity(entries.len());
189        let mut kept = Vec::with_capacity(entries.len());
190
191        for entry in entries {
192            match existing.get(&entry.name) {
193                // Same name and same kind: reuse the node wholesale.
194                Some(&cid) if self.node(cid).map(|n| n.kind) == Some(entry.kind) => {
195                    if let Some(n) = self.node_mut(cid) {
196                        // The path can still shift if an ancestor was renamed.
197                        n.path = entry.path;
198                    }
199                    kept.push(cid);
200                    new_children.push(cid);
201                }
202                // Name reused for a different kind (file replaced by a directory):
203                // that is a different thing, so it gets a fresh identity.
204                _ => new_children.push(self.push_node(Some(id), entry)),
205            }
206        }
207
208        // Anything previously present and not kept has gone from disk.
209        for (_, cid) in existing {
210            if !kept.contains(&cid) {
211                self.kill_subtree(cid);
212            }
213        }
214
215        if let Some(node) = self.node_mut(id) {
216            node.children = ChildState::Loaded(new_children);
217        }
218
219        // The selection may have just been tombstoned; fall back to something real.
220        if self.node(self.selected).is_none() {
221            self.selected = self.nearest_live_ancestor(id);
222        }
223    }
224
225    fn push_node(&mut self, parent: Option<NodeId>, entry: DirEntryInfo) -> NodeId {
226        let id = NodeId::new(self.nodes.len() as u64);
227        self.nodes.push(Node {
228            id,
229            parent,
230            name: entry.name,
231            path: entry.path,
232            kind: entry.kind,
233            expanded: false,
234            children: ChildState::Unloaded,
235            alive: true,
236        });
237        id
238    }
239
240    /// Tombstone a node and everything beneath it. Ids are never reused.
241    fn kill_subtree(&mut self, id: NodeId) {
242        let mut stack = vec![id];
243        while let Some(cur) = stack.pop() {
244            let Some(node) = self.nodes.get_mut(cur.0 as usize) else { continue };
245            node.alive = false;
246            if let ChildState::Loaded(kids) = &node.children {
247                stack.extend(kids.iter().copied());
248            }
249        }
250    }
251
252    fn nearest_live_ancestor(&self, from: NodeId) -> NodeId {
253        let mut cur = Some(from);
254        while let Some(id) = cur {
255            if self.node(id).is_some() {
256                return id;
257            }
258            cur = self.nodes.get(id.0 as usize).and_then(|n| n.parent);
259        }
260        self.root
261    }
262
263    /// The expanded tree flattened into render order (depth-first, parents before children).
264    pub fn visible_rows(&self) -> Vec<Row> {
265        let mut rows = Vec::new();
266        self.push_rows(self.root, 0, &mut rows);
267        rows
268    }
269
270    fn push_rows(&self, id: NodeId, depth: usize, out: &mut Vec<Row>) {
271        let Some(node) = self.node(id) else { return };
272        out.push(Row {
273            id,
274            depth,
275            name: node.name.to_string_lossy().into_owned(),
276            kind: node.kind,
277            expanded: node.expanded,
278            is_expandable: node.is_expandable(),
279            error: match &node.children {
280                ChildState::Error(e) => Some(e.to_string()),
281                _ => None,
282            },
283            loading: matches!(node.children, ChildState::Loading),
284        });
285
286        if !node.expanded {
287            return;
288        }
289        if let ChildState::Loaded(children) = &node.children {
290            for &child in children {
291                self.push_rows(child, depth + 1, out);
292            }
293        }
294    }
295
296    /// Find a live node by its path. Linear, but only ever over *loaded* nodes, which
297    /// is bounded by what the user has actually expanded.
298    pub fn find_by_path(&self, path: &Path) -> Option<NodeId> {
299        self.nodes.iter().find(|n| n.alive && n.path == path).map(|n| n.id)
300    }
301
302    /// Given paths that changed on disk, decide which loaded directories to re-read.
303    ///
304    /// A change to `/r/src/main.rs` means re-reading `/r/src`; a change to `/r/src`
305    /// itself means the same. Directories we have never loaded are skipped — there is
306    /// nothing to refresh, and expanding them later will read them anyway. The result is
307    /// deduplicated so a burst of edits in one directory costs one read (ADR-0005 §5).
308    pub fn dirs_to_refresh(&self, changed: &[PathBuf]) -> Vec<NodeId> {
309        let mut out: Vec<NodeId> = Vec::new();
310        for path in changed {
311            // The changed path itself if it is a loaded directory, otherwise its parent.
312            let candidate = self
313                .find_by_path(path)
314                .filter(|&id| self.is_loaded_dir(id))
315                .or_else(|| path.parent().and_then(|p| self.find_by_path(p)))
316                .filter(|&id| self.is_loaded_dir(id));
317
318            if let Some(id) = candidate {
319                if !out.contains(&id) {
320                    out.push(id);
321                }
322            }
323        }
324        out
325    }
326
327    fn is_loaded_dir(&self, id: NodeId) -> bool {
328        self.node(id).is_some_and(|n| matches!(n.children, ChildState::Loaded(_)))
329    }
330
331    /// Every directory whose contents are currently materialized, expanded or not.
332    /// Configuration reload uses this to reapply exclusion rules immediately without
333    /// walking any directory the user never opened.
334    pub fn loaded_directories(&self) -> Vec<(NodeId, PathBuf)> {
335        self.nodes
336            .iter()
337            .filter(|node| {
338                node.alive
339                    && node.kind == EntryKind::Dir
340                    && matches!(node.children, ChildState::Loaded(_))
341            })
342            .map(|node| (node.id, node.path.clone()))
343            .collect()
344    }
345
346    /// Queue a re-read of an already-loaded directory, keeping the current contents
347    /// visible until the new listing arrives. Returns the path to read.
348    #[must_use]
349    pub fn refresh(&mut self, id: NodeId) -> Option<PathBuf> {
350        self.node(id).filter(|n| n.kind == EntryKind::Dir).map(|n| n.path.clone())
351    }
352
353    // --- selection / navigation -------------------------------------------------
354
355    pub fn select(&mut self, id: NodeId) {
356        if self.node(id).is_some() {
357            self.selected = id;
358        }
359    }
360
361    /// Index of the selection within [`Self::visible_rows`], for scrolling and highlight.
362    pub fn selected_row(&self) -> usize {
363        self.visible_rows().iter().position(|r| r.id == self.selected).unwrap_or(0)
364    }
365
366    pub fn select_next(&mut self) {
367        self.move_selection(1);
368    }
369
370    pub fn select_prev(&mut self) {
371        self.move_selection(-1);
372    }
373
374    fn move_selection(&mut self, delta: isize) {
375        let rows = self.visible_rows();
376        if rows.is_empty() {
377            return;
378        }
379        let cur = rows.iter().position(|r| r.id == self.selected).unwrap_or(0) as isize;
380        // Clamp rather than wrap: arrowing past the end of a file list should rest at
381        // the end, not jump back to the top.
382        let next = (cur + delta).clamp(0, rows.len() as isize - 1) as usize;
383        self.selected = rows[next].id;
384    }
385
386    /// Collapse the selection, or step to its parent when it is already collapsed —
387    /// the conventional left-arrow behaviour in a tree.
388    pub fn collapse_or_parent(&mut self) {
389        let Some(node) = self.node(self.selected) else { return };
390        if node.is_expandable() && node.expanded {
391            let id = node.id;
392            self.collapse(id);
393        } else if let Some(parent) = node.parent {
394            self.selected = parent;
395        }
396    }
397}
398
399#[cfg(test)]
400mod tests {
401    use super::*;
402
403    fn entry(name: &str, kind: EntryKind) -> DirEntryInfo {
404        DirEntryInfo { name: name.into(), path: PathBuf::from("/r").join(name), kind }
405    }
406
407    fn dir(name: &str) -> DirEntryInfo {
408        entry(name, EntryKind::Dir)
409    }
410    fn file(name: &str) -> DirEntryInfo {
411        entry(name, EntryKind::File)
412    }
413
414    fn tree() -> FileTree {
415        FileTree::new("/r", "r")
416    }
417
418    fn names(t: &FileTree) -> Vec<String> {
419        t.visible_rows().iter().map(|r| r.name.clone()).collect()
420    }
421
422    #[test]
423    fn a_new_tree_shows_only_its_root() {
424        let t = tree();
425        assert_eq!(names(&t), ["r"]);
426        assert_eq!(t.selected(), t.root());
427    }
428
429    #[test]
430    fn expanding_requests_a_read_then_shows_children() {
431        let mut t = tree();
432        assert_eq!(t.expand(t.root()), Some(PathBuf::from("/r")), "needs a read");
433        assert!(t.visible_rows()[0].loading, "renders as loading meanwhile");
434
435        t.set_children(t.root(), vec![dir("src"), file("Cargo.toml")]);
436        assert_eq!(names(&t), ["r", "src", "Cargo.toml"]);
437    }
438
439    #[test]
440    fn expanding_an_already_loaded_directory_does_not_re_read() {
441        let mut t = tree();
442        let _ = t.expand(t.root());
443        t.set_children(t.root(), vec![dir("src")]);
444        t.collapse(t.root());
445        assert_eq!(t.expand(t.root()), None, "contents are still held; no duplicate work");
446    }
447
448    #[test]
449    fn collapsed_subtrees_contribute_no_rows() {
450        let mut t = tree();
451        let _ = t.expand(t.root());
452        t.set_children(t.root(), vec![dir("src")]);
453        let src = t.visible_rows()[1].id;
454        let _ = t.expand(src);
455        t.set_children(src, vec![file("main.rs")]);
456        assert_eq!(names(&t), ["r", "src", "main.rs"]);
457
458        t.collapse(src);
459        assert_eq!(names(&t), ["r", "src"], "children of a collapsed dir are not rendered");
460    }
461
462    #[test]
463    fn files_never_expand() {
464        let mut t = tree();
465        let _ = t.expand(t.root());
466        t.set_children(t.root(), vec![file("README.md")]);
467        let readme = t.visible_rows()[1].id;
468        assert_eq!(t.expand(readme), None);
469        assert!(!t.visible_rows()[1].is_expandable);
470    }
471
472    #[test]
473    fn symlinks_are_not_expandable() {
474        let mut t = tree();
475        let _ = t.expand(t.root());
476        t.set_children(t.root(), vec![entry("link", EntryKind::Symlink)]);
477        let link = t.visible_rows()[1].id;
478        assert_eq!(t.expand(link), None, "symlinks are shown but not traversed");
479    }
480
481    #[test]
482    fn a_failed_read_is_recorded_on_the_node_not_fatal() {
483        let mut t = tree();
484        let _ = t.expand(t.root());
485        t.set_children(t.root(), vec![dir("secret"), file("ok.txt")]);
486        let secret = t.visible_rows()[1].id;
487        let _ = t.expand(secret);
488        t.set_error(secret, FsError::PermissionDenied(PathBuf::from("/r/secret")));
489
490        let rows = t.visible_rows();
491        assert!(rows[1].error.as_ref().unwrap().contains("permission denied"));
492        assert_eq!(rows[2].name, "ok.txt", "the sibling still renders");
493    }
494
495    #[test]
496    fn a_failed_read_can_be_retried() {
497        let mut t = tree();
498        let _ = t.expand(t.root());
499        t.set_error(t.root(), FsError::PermissionDenied(PathBuf::from("/r")));
500        assert_eq!(t.begin_load(t.root()), Some(PathBuf::from("/r")), "errors are retryable");
501    }
502
503    // --- reconciliation: the point of the arena ---------------------------------
504
505    #[test]
506    fn re_reading_preserves_node_identity_and_expansion() {
507        let mut t = tree();
508        let _ = t.expand(t.root());
509        t.set_children(t.root(), vec![dir("src"), file("a.txt")]);
510        let src = t.visible_rows()[1].id;
511        let _ = t.expand(src);
512        t.set_children(src, vec![file("main.rs")]);
513
514        // A watch event re-reads the root with the same entries plus one.
515        t.set_children(t.root(), vec![dir("src"), file("a.txt"), file("b.txt")]);
516
517        assert_eq!(t.visible_rows()[1].id, src, "src keeps its NodeId");
518        assert_eq!(
519            names(&t),
520            ["r", "src", "main.rs", "a.txt", "b.txt"],
521            "src stays expanded and keeps its loaded children"
522        );
523    }
524
525    #[test]
526    fn vanished_entries_disappear_from_the_tree() {
527        let mut t = tree();
528        let _ = t.expand(t.root());
529        t.set_children(t.root(), vec![file("gone.txt"), file("stays.txt")]);
530        t.set_children(t.root(), vec![file("stays.txt")]);
531        assert_eq!(names(&t), ["r", "stays.txt"]);
532    }
533
534    #[test]
535    fn ids_are_never_reused_after_a_delete() {
536        let mut t = tree();
537        let _ = t.expand(t.root());
538        t.set_children(t.root(), vec![file("gone.txt")]);
539        let gone = t.visible_rows()[1].id;
540
541        t.set_children(t.root(), vec![file("new.txt")]);
542        let new = t.visible_rows()[1].id;
543        assert_ne!(gone, new, "a fresh entry must not inherit a dead id");
544        assert!(t.node(gone).is_none(), "the dead node is unreachable");
545    }
546
547    #[test]
548    fn a_name_reused_for_a_different_kind_gets_a_fresh_identity() {
549        let mut t = tree();
550        let _ = t.expand(t.root());
551        t.set_children(t.root(), vec![file("thing")]);
552        let as_file = t.visible_rows()[1].id;
553
554        t.set_children(t.root(), vec![dir("thing")]);
555        let as_dir = t.visible_rows()[1].id;
556        assert_ne!(as_file, as_dir, "file replaced by a directory is a different thing");
557        assert!(t.visible_rows()[1].is_expandable);
558    }
559
560    #[test]
561    fn selection_survives_a_re_read_that_keeps_it() {
562        let mut t = tree();
563        let _ = t.expand(t.root());
564        t.set_children(t.root(), vec![file("a.txt"), file("b.txt")]);
565        let b = t.visible_rows()[2].id;
566        t.select(b);
567
568        t.set_children(t.root(), vec![file("a.txt"), file("b.txt"), file("c.txt")]);
569        assert_eq!(t.selected(), b, "selection is untouched by unrelated churn");
570    }
571
572    #[test]
573    fn selection_falls_back_when_the_selected_node_is_deleted() {
574        let mut t = tree();
575        let _ = t.expand(t.root());
576        t.set_children(t.root(), vec![file("doomed.txt")]);
577        let doomed = t.visible_rows()[1].id;
578        t.select(doomed);
579
580        t.set_children(t.root(), vec![file("other.txt")]);
581        assert_eq!(t.selected(), t.root(), "falls back to a live ancestor, never dangles");
582        assert!(t.node(t.selected()).is_some());
583    }
584
585    // --- navigation --------------------------------------------------------------
586
587    #[test]
588    fn navigation_walks_visible_rows_and_clamps_at_the_ends() {
589        let mut t = tree();
590        let _ = t.expand(t.root());
591        t.set_children(t.root(), vec![file("a"), file("b")]);
592
593        t.select_next();
594        assert_eq!(t.selected_row(), 1);
595        t.select_next();
596        assert_eq!(t.selected_row(), 2);
597        t.select_next();
598        assert_eq!(t.selected_row(), 2, "clamps at the bottom rather than wrapping");
599
600        t.select_prev();
601        t.select_prev();
602        t.select_prev();
603        assert_eq!(t.selected_row(), 0, "clamps at the top");
604    }
605
606    #[test]
607    fn navigation_skips_collapsed_children() {
608        let mut t = tree();
609        let _ = t.expand(t.root());
610        t.set_children(t.root(), vec![dir("src"), file("z.txt")]);
611        let src = t.visible_rows()[1].id;
612        let _ = t.expand(src);
613        t.set_children(src, vec![file("hidden_when_collapsed.rs")]);
614        t.collapse(src);
615
616        t.select(src);
617        t.select_next();
618        assert_eq!(t.visible_rows()[t.selected_row()].name, "z.txt");
619    }
620
621    #[test]
622    fn left_collapses_then_steps_to_the_parent() {
623        let mut t = tree();
624        let _ = t.expand(t.root());
625        t.set_children(t.root(), vec![dir("src")]);
626        let src = t.visible_rows()[1].id;
627        let _ = t.expand(src);
628        t.set_children(src, vec![file("main.rs")]);
629        t.select(src);
630
631        t.collapse_or_parent();
632        assert!(!t.visible_rows()[1].expanded, "first press collapses");
633        t.collapse_or_parent();
634        assert_eq!(t.selected(), t.root(), "second press moves to the parent");
635    }
636
637    // --- watch-event routing -----------------------------------------------------
638
639    fn loaded_tree() -> (FileTree, NodeId) {
640        let mut t = tree();
641        let _ = t.expand(t.root());
642        t.set_children(t.root(), vec![dir("src"), file("a.txt")]);
643        let src = t.visible_rows()[1].id;
644        let _ = t.expand(src);
645        t.set_children(src, vec![file("main.rs")]);
646        (t, src)
647    }
648
649    #[test]
650    fn a_changed_file_refreshes_its_containing_directory() {
651        let (t, src) = loaded_tree();
652        let changed = vec![PathBuf::from("/r/src/main.rs")];
653        assert_eq!(t.dirs_to_refresh(&changed), vec![src]);
654    }
655
656    #[test]
657    fn a_changed_directory_refreshes_itself() {
658        let (t, src) = loaded_tree();
659        assert_eq!(t.dirs_to_refresh(&[PathBuf::from("/r/src")]), vec![src]);
660    }
661
662    #[test]
663    fn a_burst_in_one_directory_coalesces_to_one_read() {
664        let (t, src) = loaded_tree();
665        let changed = vec![
666            PathBuf::from("/r/src/main.rs"),
667            PathBuf::from("/r/src/other.rs"),
668            PathBuf::from("/r/src/third.rs"),
669        ];
670        assert_eq!(t.dirs_to_refresh(&changed), vec![src], "deduplicated to one read");
671    }
672
673    #[test]
674    fn changes_under_unloaded_directories_are_ignored() {
675        let mut t = tree();
676        let _ = t.expand(t.root());
677        t.set_children(t.root(), vec![dir("never_opened")]);
678        // The directory exists in the tree but was never expanded, so there is nothing
679        // to reconcile and no read worth doing.
680        let changed = vec![PathBuf::from("/r/never_opened/whatever.rs")];
681        assert!(t.dirs_to_refresh(&changed).is_empty());
682    }
683
684    #[test]
685    fn changes_outside_the_tree_are_ignored() {
686        let (t, _) = loaded_tree();
687        assert!(t.dirs_to_refresh(&[PathBuf::from("/somewhere/else/x.rs")]).is_empty());
688    }
689
690    #[test]
691    fn find_by_path_only_returns_live_nodes() {
692        let mut t = tree();
693        let _ = t.expand(t.root());
694        t.set_children(t.root(), vec![file("doomed.txt")]);
695        assert!(t.find_by_path(Path::new("/r/doomed.txt")).is_some());
696
697        t.set_children(t.root(), vec![]);
698        assert!(t.find_by_path(Path::new("/r/doomed.txt")).is_none(), "tombstones stay hidden");
699    }
700
701    #[test]
702    fn toggle_expands_then_collapses() {
703        let mut t = tree();
704        assert!(t.toggle(t.root()).is_some(), "first toggle asks for a read");
705        t.set_children(t.root(), vec![file("a")]);
706        assert_eq!(names(&t), ["r", "a"]);
707        assert!(t.toggle(t.root()).is_none());
708        assert_eq!(names(&t), ["r"]);
709    }
710}