Skip to main content

tree_space/fs/
model.rs

1//! A lazy file-tree model.
2//!
3//! The model is deliberately free of GTK and I/O: it stores a small directory
4//! cache keyed by path, expands lazily (children are only read when a directory
5//! is expanded), and translates filesystem change events into incremental
6//! cache updates. Directory content enters through the [`DirSource`] trait, so
7//! the model can be unit-tested against an in-memory filesystem and driven by
8//! the real `std::fs` reader in production ([`StdDirSource`]).
9
10use std::collections::HashMap;
11use std::fs;
12use std::io;
13use std::path::{Path, PathBuf};
14use std::time::SystemTime;
15
16/// A single directory entry as discovered by a [`DirSource`].
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct EntryInfo {
19    /// File name (file stem last component), not the full path.
20    pub name: String,
21    /// Absolute path of the entry.
22    pub path: PathBuf,
23    /// Whether the entry is a directory (following symlinks).
24    pub is_dir: bool,
25    /// Whether the entry is a symlink.
26    pub is_symlink: bool,
27    /// Size in bytes (0 for directories; metadata that failed is 0).
28    pub size: u64,
29    /// Last modification time, when the source could read it.
30    pub modified: Option<SystemTime>,
31}
32
33/// The seam from which the model obtains directory listings.
34///
35/// Implementations must return entries in arbitrary order; the model sorts.
36pub trait DirSource {
37    fn list_dir(&self, path: &Path) -> io::Result<Vec<EntryInfo>>;
38}
39
40/// Reads directory listings from the real filesystem via `std::fs`.
41#[derive(Debug, Default, Clone, Copy)]
42pub struct StdDirSource;
43
44impl DirSource for StdDirSource {
45    fn list_dir(&self, path: &Path) -> io::Result<Vec<EntryInfo>> {
46        let mut entries = Vec::new();
47        for entry in fs::read_dir(path)? {
48            let entry = entry?;
49            let file_type = entry.file_type()?;
50            let is_symlink = file_type.is_symlink();
51            // Following the symlink tells us whether it points at a directory,
52            // which decides whether the entry can be expanded in the tree.
53            let is_dir = if file_type.is_dir() {
54                true
55            } else if is_symlink {
56                fs::metadata(entry.path()).map(|m| m.is_dir()).unwrap_or(false)
57            } else {
58                false
59            };
60            let meta = entry.metadata().ok();
61            let size = if is_dir {
62                0
63            } else {
64                meta.as_ref().map(|m| m.len()).unwrap_or(0)
65            };
66            let modified = meta.as_ref().and_then(|m| m.modified().ok());
67            entries.push(EntryInfo {
68                name: entry.file_name().to_string_lossy().into_owned(),
69                path: entry.path(),
70                is_dir,
71                is_symlink,
72                size,
73                modified,
74            });
75        }
76        Ok(entries)
77    }
78}
79
80/// The primary key used to order entries within a directory.
81#[derive(Debug, Clone, Copy, PartialEq, Eq)]
82pub enum SortKey {
83    /// Case-insensitive name.
84    Name,
85    /// Size in bytes (directories sort as 0 unless `dirs_first`).
86    Size,
87    /// Last modification time; entries without one sort first when ascending.
88    Modified,
89    /// Kind: directories, then grouped by extension, then name.
90    Type,
91}
92
93impl SortKey {
94    /// Short configuration token (`name`, `size`, `modified`, `type`).
95    pub fn as_str(&self) -> &'static str {
96        match self {
97            SortKey::Name => "name",
98            SortKey::Size => "size",
99            SortKey::Modified => "modified",
100            SortKey::Type => "type",
101        }
102    }
103
104    /// Parse a configuration token; unknown values are an error.
105    pub fn parse(raw: &str) -> Option<Self> {
106        match raw.to_ascii_lowercase().as_str() {
107            "name" => Some(SortKey::Name),
108            "size" => Some(SortKey::Size),
109            "modified" | "mtime" | "date" => Some(SortKey::Modified),
110            "type" | "kind" | "ext" | "extension" => Some(SortKey::Type),
111            _ => None,
112        }
113    }
114}
115
116/// Sorting rules applied to every directory listing.
117#[derive(Debug, Clone, Copy, PartialEq, Eq)]
118pub struct SortOptions {
119    /// Directories before files; names always sorted alphabetically
120    /// (case-insensitively).
121    pub dirs_first: bool,
122    /// Primary ordering key.
123    pub key: SortKey,
124    /// Ascending when true, descending when false.
125    pub ascending: bool,
126}
127
128impl Default for SortOptions {
129    fn default() -> Self {
130        Self {
131            dirs_first: true,
132            key: SortKey::Name,
133            ascending: true,
134        }
135    }
136}
137
138/// The extension used as a type key: lowercase text after the final dot, or an
139/// empty string when the name has none (or is a dotfile without an extension).
140fn type_key(name: &str) -> String {
141    match name.rsplit_once('.') {
142        Some((stem, ext)) if !stem.is_empty() && !ext.is_empty() => ext.to_lowercase(),
143        _ => String::new(),
144    }
145}
146
147/// Compare two entries under the given rules. Directories always float to the
148/// top when `dirs_first` is set (regardless of key or direction); the primary
149/// `key` then orders within a group, with a lowercase-name tie-break for
150/// stability.
151fn compare_entries(a: &EntryInfo, b: &EntryInfo, opts: SortOptions) -> std::cmp::Ordering {
152    use std::cmp::Ordering;
153
154    if opts.dirs_first {
155        match (a.is_dir, b.is_dir) {
156            (true, false) => return Ordering::Less,
157            (false, true) => return Ordering::Greater,
158            _ => {}
159        }
160    }
161
162    // Primary key. For Name, direction is applied normally; Size/Modified/
163    // Type fall back to ascending name when the primary values tie.
164    let primary = match opts.key {
165        SortKey::Name => a
166            .name
167            .to_lowercase()
168            .cmp(&b.name.to_lowercase())
169            .then_with(|| a.name.cmp(&b.name)),
170        SortKey::Size => a.size.cmp(&b.size),
171        SortKey::Modified => a.modified.cmp(&b.modified),
172        SortKey::Type => type_key(&a.name)
173            .cmp(&type_key(&b.name))
174            .then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase())),
175    };
176
177    if primary == Ordering::Equal && opts.key != SortKey::Name {
178        let tie = a
179            .name
180            .to_lowercase()
181            .cmp(&b.name.to_lowercase())
182            .then_with(|| a.name.cmp(&b.name));
183        return if opts.ascending { tie } else { tie.reverse() };
184    }
185
186    if opts.ascending {
187        primary
188    } else {
189        primary.reverse()
190    }
191}
192
193/// Compare entries, ready for use with `sort_by`.
194fn sort_dir_entries(entries: &mut [EntryInfo], opts: SortOptions) {
195    entries.sort_by(|a, b| compare_entries(a, b, opts));
196}
197
198/// Per-directory cached state inside the model.
199#[derive(Debug, Default)]
200pub struct DirNode {
201    /// Whether the listing has been read (at least once).
202    pub loaded: bool,
203    /// Whether children should be rendered.
204    pub expanded: bool,
205    /// The last listing read (sorted, hidden-filtered).
206    pub entries: Vec<EntryInfo>,
207}
208
209/// A change that happened on disk, in model vocabulary. Produced by
210/// [`super::watcher`] from raw notify events and by file operations.
211#[derive(Debug, Clone, PartialEq, Eq)]
212pub enum Change {
213    Created { path: PathBuf },
214    Removed { path: PathBuf },
215    Renamed { from: PathBuf, to: PathBuf },
216    Modified { path: PathBuf },
217    /// The backing store is in an unknown state; drop every cache.
218    Rescan,
219}
220
221/// One rendered line of the tree. The UI derives both its widget and its
222/// indentation from this value; no GTK types leak in here.
223#[derive(Debug, Clone, PartialEq, Eq)]
224pub struct VisibleRow {
225    pub path: PathBuf,
226    pub name: String,
227    pub depth: usize,
228    pub is_dir: bool,
229    pub is_symlink: bool,
230    pub expanded: bool,
231    pub has_children: bool,
232    /// Whether the name matched the current filter. Used for highlighting.
233    pub matches: bool,
234}
235
236/// The lazy tree model. All methods are `&mut self` because expand/collapse
237/// mutate the cache; the UI mutates it freely since it owns it.
238pub struct TreeModel {
239    root: PathBuf,
240    sort: SortOptions,
241    show_hidden: bool,
242    filter: String,
243    /// Keyed by directory path. The root always has a node; other directories
244    /// get nodes when first expanded and are dropped when collapsed again.
245    dirs: HashMap<PathBuf, DirNode>,
246}
247
248impl TreeModel {
249    /// Create a model rooted at `root`, applying `sort` and hiding dotfiles
250    /// unless `show_hidden`. The root node is created eagerly but its listing
251    /// is only read on demand (lazy — the panel can start without blocking).
252    pub fn new(root: PathBuf, sort: SortOptions, show_hidden: bool) -> Self {
253        let root_node = DirNode {
254            loaded: false,
255            expanded: true,
256            entries: Vec::new(),
257        };
258        let mut dirs = HashMap::new();
259        dirs.insert(root.clone(), root_node);
260        Self {
261            root,
262            sort,
263            show_hidden,
264            filter: String::new(),
265            dirs,
266        }
267    }
268
269    /// The tree root directory.
270    pub fn root(&self) -> &Path {
271        &self.root
272    }
273
274    /// The current filter (lowercased). Empty means "no filter".
275    pub fn filter(&self) -> &str {
276        &self.filter
277    }
278
279    /// Set the type-ahead filter. Empty string clears it.
280    pub fn set_filter(&mut self, filter: &str) {
281        self.filter = filter.trim().to_lowercase();
282    }
283
284    /// Current sorting rules.
285    pub fn sort(&self) -> SortOptions {
286        self.sort
287    }
288
289    /// Replace the sorting rules. Callers must re-read loaded listings
290    /// (see [`Self::reload_all`]) for the change to take effect, since nodes
291    /// cache their already-sorted entries.
292    pub fn set_sort(&mut self, sort: SortOptions) {
293        self.sort = sort;
294    }
295
296    /// Whether hidden (dot) entries are currently shown.
297    pub fn show_hidden(&self) -> bool {
298        self.show_hidden
299    }
300
301    /// Toggle hidden-file visibility. Callers must re-read loaded listings
302    /// (see [`Self::reload_all`]) for the change to take effect.
303    pub fn set_show_hidden(&mut self, show_hidden: bool) {
304        self.show_hidden = show_hidden;
305    }
306
307    /// Re-read every loaded directory so sort/hidden changes take effect.
308    /// Expanded nodes are refreshed; collapses are unaffected. Errors on a
309    /// single directory are ignored (that node simply becomes empty).
310    pub fn reload_all(&mut self, src: &dyn DirSource) {
311        let paths: Vec<PathBuf> = self
312            .dirs
313            .iter()
314            .filter(|(_, node)| node.loaded)
315            .map(|(path, _)| path.clone())
316            .collect();
317        for path in paths {
318            let _ = self.reload(&path, src);
319        }
320    }
321
322    /// Whether `path` (strictly below, or equal to, the root) belongs to this tree.
323    pub fn under_root(&self, path: &Path) -> bool {
324        path.starts_with(&self.root)
325    }
326
327    /// True when the cached node for `path` is considered expanded.
328    pub fn is_expanded(&self, path: &Path) -> bool {
329        self.dirs.get(path).is_some_and(|n| n.expanded)
330    }
331
332    /// Cached listing for `path`, if that directory has been loaded.
333    pub fn node(&self, path: &Path) -> Option<&DirNode> {
334        self.dirs.get(path)
335    }
336
337    /// Paths of every directory that is expanded *and* loaded — the set of
338    /// directories the UI should be watching (the root is always in this set
339    /// once open_root has expanded it). Used to keep the filesystem watcher
340    /// aligned with the lazily-expanded tree.
341    pub fn expanded_loaded_dirs(&self) -> Vec<PathBuf> {
342        self.dirs
343            .iter()
344            .filter(|(_, node)| node.loaded && node.expanded)
345            .map(|(path, _)| path.clone())
346            .collect()
347    }
348
349    /// Expand `path`, reading its listing the first time. Collapsed state is
350    /// untouched: expanding a previously loaded (then collapsed) node is cheap.
351    pub fn expand(&mut self, path: &Path, src: &dyn DirSource) -> Result<(), io::Error> {
352        if let Some(node) = self.dirs.get_mut(path)
353            && node.loaded
354            && node.expanded
355        {
356            return Ok(());
357        }
358        let result = self.reload(path, src);
359        // `reload` preserves the old flag (absent for a fresh node -> false);
360        // force the expansion the caller asked for.
361        if let Some(node) = self.dirs.get_mut(path) {
362            node.expanded = true;
363        }
364        result
365    }
366
367    /// Collapse `path` and drop its cached listing to free memory.
368    pub fn collapse(&mut self, path: &Path) {
369        if let Some(node) = self.dirs.get_mut(path) {
370            node.expanded = false;
371            node.entries.clear();
372            node.loaded = false;
373            self.remove_subtree_suffixes(path);
374        }
375    }
376
377    /// Toggle expansion state of `path`, loading children on expand.
378    pub fn toggle(&mut self, path: &Path, src: &dyn DirSource) -> Result<(), io::Error> {
379        let expanded = self.is_expanded(path);
380        if expanded {
381            self.collapse(path);
382            Ok(())
383        } else {
384            self.expand(path, src)
385        }
386    }
387
388    /// Reload a previously loaded directory (e.g. after a change event).
389    fn reload(&mut self, path: &Path, src: &dyn DirSource) -> Result<(), io::Error> {
390        // Retain the expanded flag; reset everything else.
391        let expanded = self.dirs.get(path).is_some_and(|n| n.expanded);
392        let mut node = DirNode {
393            // An unreadable directory becomes an empty (but loaded) node so we
394            // do not retry every render; the error is returned to the caller.
395            loaded: true,
396            expanded,
397            entries: Vec::new(),
398        };
399        let result = src.list_dir(path);
400        match result {
401            Ok(list) => {
402                let mut entries = list;
403                if !self.show_hidden {
404                    entries.retain(|e| !is_hidden(&e.name));
405                }
406                sort_dir_entries(&mut entries, self.sort);
407                node.entries = entries;
408            }
409            Err(err) => {
410                self.dirs.insert(path.to_path_buf(), node);
411                return Err(err);
412            }
413        }
414        self.dirs.insert(path.to_path_buf(), node);
415        Ok(())
416    }
417
418    /// Apply a change event. Only loaded directories are refreshed, so unloaded
419    /// parts of the tree stay untouched (cheap) and lazily re-read later anyway.
420    pub fn apply(&mut self, change: &Change, src: &dyn DirSource) {
421        match change {
422            Change::Created { path } => self.refresh_parent(path, src),
423            Change::Removed { path } => {
424                self.remove_subtree(path);
425                self.refresh_parent(path, src);
426            }
427            Change::Renamed { from, to } => {
428                self.remove_subtree(from);
429                self.refresh_parent(from, src);
430                self.refresh_parent(to, src);
431            }
432            Change::Modified { path } => {
433                // Content and metadata edits do not change the tree structure;
434                // only refresh a directory node if the modification touched one
435                // (covers renames by tools that report Modify instead of
436                // Create/Remove).
437                if self.dirs.get(path).is_some_and(|n| n.loaded) {
438                    let _ = self.reload(path, src);
439                }
440            }
441            Change::Rescan => {
442                // The backing store is untrusted (e.g. an inotify queue
443                // overflow). Reload every directory that was loaded *and*
444                // expanded — exactly the set the user can see — rather than
445                // dropping the whole cache and reloading only the root, which
446                // made a rescan look like a full tree collapse. Nodes that were
447                // loaded but collapsed are simply dropped and lazily re-read
448                // when next expanded.
449                //
450                // The reload is capped so a pathological tree cannot turn a
451                // single rescan into an unbounded synchronous sweep; past the
452                // cap the remaining nodes just fall back to lazy loading.
453                const MAX_RESCAN_DIRS: usize = 512;
454                let mut reload: Vec<PathBuf> = self
455                    .dirs
456                    .iter()
457                    .filter(|(_, node)| node.loaded && node.expanded)
458                    .map(|(path, _)| path.clone())
459                    .collect();
460                reload.sort();
461                reload.truncate(MAX_RESCAN_DIRS);
462                for node in self.dirs.values_mut() {
463                    node.loaded = false;
464                    node.entries.clear();
465                }
466                for path in reload {
467                    let _ = self.reload(&path, src);
468                }
469            }
470        }
471    }
472
473    /// Refresh the parent dir of `path` if there is one and it is loaded.
474    fn refresh_parent(&mut self, path: &Path, src: &dyn DirSource) {
475        let Some(parent) = path.parent() else {
476            return; // root itself — we do not model / below the root.
477        };
478        if self.dirs.get(parent).is_some_and(|n| n.loaded) {
479            let _ = self.reload(parent, src);
480        }
481    }
482
483    /// Drop the node at `path` and every descendant cache below it.
484    fn remove_subtree(&mut self, path: &Path) {
485        let keys: Vec<PathBuf> = self
486            .dirs
487            .keys()
488            .filter(|k| k.starts_with(path))
489            .cloned()
490            .collect();
491        for key in keys {
492            self.dirs.remove(&key);
493        }
494    }
495
496    /// Remove cached descendants of `path`, but keep `path` itself.
497    /// Used by [`Self::collapse`] so that re-expanding still knows the node.
498    fn remove_subtree_suffixes(&mut self, path: &Path) {
499        let keys: Vec<PathBuf> = self
500            .dirs
501            .keys()
502            .filter(|k| k.starts_with(path) && *k != path)
503            .cloned()
504            .collect();
505        for key in keys {
506            self.dirs.remove(&key);
507        }
508    }
509
510    /// Render the visible rows of the tree in display order.
511    ///
512    /// The root directory itself is **not** included as a row — its path is
513    /// shown in the toolbar path entry instead. Children of the root start at
514    /// depth 0.
515    ///
516    /// Filter semantics (MVP): the filter narrows the children shown beneath
517    /// each expanded directory to name matches, and keeps a directory row
518    /// visible (even when the name itself does not match) as long as a *loaded*
519    /// descendant matches — the classic "show the path to the match" behaviour.
520    /// Searching *inside* not-yet-expanded directories is intentionally not
521    /// performed, to preserve lazy loading — full-tree search arrives with
522    /// Phase 2 (ripgrep).
523    pub fn visible_rows(&self) -> Vec<VisibleRow> {
524        let mut rows = Vec::new();
525        self.visible_rows_into(&mut rows);
526        rows
527    }
528
529    /// Same as [`Self::visible_rows`], but clears and refills a caller-owned
530    /// buffer. Reusing one `Vec` across rebuilds avoids re-allocating the full
531    /// row list (and its `PathBuf`/`String` payloads) on every interaction.
532    pub fn visible_rows_into(&self, out: &mut Vec<VisibleRow>) {
533        out.clear();
534        self.collect_children(&self.root, 0, out);
535    }
536
537    /// Render the children of `path` (one level at a time) into `out`.
538    ///
539    /// Each directory renders exactly one row — the one produced at its parent —
540    /// so recursion here emits *children only*, never the directory itself.
541    fn collect_children(&self, path: &Path, depth: usize, out: &mut Vec<VisibleRow>) {
542        let Some(node) = self.dirs.get(path) else {
543            return;
544        };
545        if !node.expanded || !node.loaded {
546            return;
547        }
548        let filtering = !self.filter.is_empty();
549        for entry in &node.entries {
550            let name_matches =
551                filtering && entry.name.to_lowercase().contains(&self.filter);
552            // Directories are kept when their own name matches or any loaded
553            // descendant does (so the path to the match stays visible).
554            let kept = if filtering {
555                if entry.is_dir {
556                    name_matches || self.subtree_has_match(&entry.path)
557                } else {
558                    name_matches
559                }
560            } else {
561                true
562            };
563            if !kept {
564                continue;
565            }
566            let is_dir = entry.is_dir;
567            let child_node = self.dirs.get(&entry.path);
568            let expanded = child_node.is_some_and(|n| n.expanded);
569            let has_children = child_node.is_some_and(|n| n.loaded && !n.entries.is_empty());
570            out.push(VisibleRow {
571                path: entry.path.clone(),
572                name: entry.name.clone(),
573                depth,
574                is_dir,
575                is_symlink: entry.is_symlink,
576                expanded,
577                has_children,
578                matches: filtering && kept,
579            });
580            if is_dir {
581                self.collect_children(&entry.path, depth + 1, out);
582            }
583        }
584    }
585
586    /// Whether any *loaded* descendant of `dir` matches the filter. Only
587    /// expanded (thus loaded) directories are scanned, honouring the lazy
588    /// "no peeking" rule of [`Self::visible_rows`].
589    fn subtree_has_match(&self, dir: &Path) -> bool {
590        let Some(node) = self.dirs.get(dir) else {
591            return false;
592        };
593        if !node.loaded || !node.expanded {
594            return false;
595        }
596        node.entries.iter().any(|e| {
597            e.name.to_lowercase().contains(&self.filter)
598                || (e.is_dir && self.subtree_has_match(&e.path))
599        })
600    }
601}
602
603/// A dotfile unless it is exactly `.` or `..` (never present in listings, but
604/// staying defensive keeps dots on `.`/`..` safe if they ever leak through).
605pub(crate) fn is_hidden(name: &str) -> bool {
606    name.starts_with('.') && name != "." && name != ".."
607}
608
609#[cfg(test)]
610mod tests {
611    use super::*;
612
613    /// In-memory filesystem for tests.
614    #[derive(Default)]
615    struct MemFs {
616        dirs: HashMap<PathBuf, Vec<EntryInfo>>,
617    }
618
619    impl MemFs {
620        fn dir(&mut self, path: impl AsRef<Path>, children: Vec<(&str, bool, bool)>) {
621            let path = path.as_ref();
622            let entries = children
623                .into_iter()
624                .map(|(name, is_dir, is_symlink)| EntryInfo {
625                    name: name.to_owned(),
626                    path: path.join(name),
627                    is_dir,
628                    is_symlink,
629                    size: 0,
630                    modified: None,
631                })
632                .collect();
633            self.dirs.insert(path.to_path_buf(), entries);
634        }
635
636        /// Like [`Self::dir`], but each child carries a `(size, mtime_secs)`
637        /// pair so size/date sorting can be exercised.
638        fn dir_sized(&mut self, path: impl AsRef<Path>, children: Vec<(&str, bool, bool, u64, u64)>) {
639            let path = path.as_ref();
640            let entries = children
641                .into_iter()
642                .map(|(name, is_dir, is_symlink, size, secs)| EntryInfo {
643                    name: name.to_owned(),
644                    path: path.join(name),
645                    is_dir,
646                    is_symlink,
647                    size,
648                    modified: Some(std::time::UNIX_EPOCH + std::time::Duration::from_secs(secs)),
649                })
650                .collect();
651            self.dirs.insert(path.to_path_buf(), entries);
652        }
653    }
654
655    impl DirSource for MemFs {
656        fn list_dir(&self, path: &Path) -> io::Result<Vec<EntryInfo>> {
657            self.dirs.get(path).cloned().ok_or_else(|| {
658                io::Error::new(io::ErrorKind::NotFound, format!("no such dir: {}", path.display()))
659            })
660        }
661    }
662
663    fn row_names(rows: &[VisibleRow]) -> Vec<String> {
664        rows.iter().map(|r| r.name.clone()).collect()
665    }
666
667    #[test]
668    fn visible_rows_into_reuses_and_clears_buffer() {
669        let mut fs = MemFs::default();
670        fs.dir("/r", vec![("a", true, false), ("b.txt", false, false)]);
671        let mut model = TreeModel::new("/r".into(), SortOptions::default(), false);
672        model.expand(Path::new("/r"), &fs).unwrap();
673
674        // A buffer pre-seeded with a stale row must be cleared, not appended to,
675        // yet keep its allocation.
676        let mut buf = vec![VisibleRow {
677            path: "/stale".into(),
678            name: "stale".into(),
679            depth: 9,
680            is_dir: false,
681            is_symlink: false,
682            expanded: false,
683            has_children: false,
684            matches: false,
685        }];
686        model.visible_rows_into(&mut buf);
687        assert_eq!(row_names(&buf), vec!["a", "b.txt"]);
688
689        // Refilling with a collapsed tree empties it.
690        model.collapse(Path::new("/r"));
691        model.visible_rows_into(&mut buf);
692        assert!(buf.is_empty());
693    }
694
695    #[test]
696    fn starts_with_just_the_root() {
697        // Root itself is not a row (it shows in the toolbar); no children loaded
698        // yet, so visible_rows is empty.
699        let model = TreeModel::new("/r".into(), SortOptions::default(), false);
700        let rows = model.visible_rows();
701        assert_eq!(rows.len(), 0);
702    }
703
704    #[test]
705    fn expand_is_lazy_and_shows_sorted_children() {
706        let mut fs = MemFs::default();
707        fs.dir("/r", vec![("z.txt", false, false), ("a", true, false), ("b.txt", false, false)]);
708        let mut model = TreeModel::new("/r".into(), SortOptions::default(), false);
709
710        model.expand(Path::new("/r"), &fs).unwrap();
711        let rows = model.visible_rows();
712        // Root row is now absent; children of root start at depth 0.
713        assert_eq!(row_names(&rows), vec!["a", "b.txt", "z.txt"]); // dirs first
714        assert_eq!(rows[0].depth, 0);
715        assert!(rows[0].is_dir);
716    }
717
718    #[test]
719    fn nested_expansion_and_depth() {
720        let mut fs = MemFs::default();
721        fs.dir("/r", vec![("a", true, false)]);
722        fs.dir("/r/a", vec![("b", true, false)]);
723        fs.dir("/r/a/b", vec![("file.txt", false, false)]);
724
725        let mut model = TreeModel::new("/r".into(), SortOptions::default(), false);
726        model.expand(Path::new("/r"), &fs).unwrap();
727        model.expand(Path::new("/r/a"), &fs).unwrap();
728        model.expand(Path::new("/r/a/b"), &fs).unwrap();
729
730        let rows = model.visible_rows();
731        // Root excluded: depths are 0, 1, 2 instead of 0, 1, 2, 3.
732        let depths: Vec<usize> = rows.iter().map(|r| r.depth).collect();
733        assert_eq!(depths, vec![0, 1, 2]);
734        assert_eq!(row_names(&rows), vec!["a", "b", "file.txt"]);
735    }
736
737    #[test]
738    fn collapse_hides_children() {
739        let mut fs = MemFs::default();
740        fs.dir("/r", vec![("a", true, false)]);
741        fs.dir("/r/a", vec![("x", false, false)]);
742
743        let mut model = TreeModel::new("/r".into(), SortOptions::default(), false);
744        model.expand(Path::new("/r"), &fs).unwrap();
745        model.expand(Path::new("/r/a"), &fs).unwrap();
746        // Root not shown; "a" and "x" remain.
747        assert_eq!(row_names(&model.visible_rows()), vec!["a", "x"]);
748
749        model.collapse(Path::new("/r/a"));
750        assert_eq!(row_names(&model.visible_rows()), vec!["a"]);
751        assert!(!model.is_expanded(Path::new("/r/a")));
752    }
753
754    #[test]
755    fn sorting_respects_dirs_first_and_case() {
756        let mut fs = MemFs::default();
757        fs.dir(
758            "/r",
759            vec![
760                ("beta", false, false),
761                ("Alpha", false, false),
762                ("GAMMA", true, false),
763                ("alpha", false, false),
764            ],
765        );
766        let mut model = TreeModel::new("/r".into(), SortOptions::default(), false);
767        model.expand(Path::new("/r"), &fs).unwrap();
768        // Root excluded; dirs first, then case-insensitive alphabetical.
769        assert_eq!(row_names(&model.visible_rows()), vec!["GAMMA", "Alpha", "alpha", "beta"]);
770    }
771
772    #[test]
773    fn dirs_first_disabled_interleaves() {
774        let mut fs = MemFs::default();
775        fs.dir("/r", vec![("b", true, false), ("a", false, false)]);
776        let mut model = TreeModel::new(
777            "/r".into(),
778            SortOptions { dirs_first: false, ..SortOptions::default() },
779            false,
780        );
781        model.expand(Path::new("/r"), &fs).unwrap();
782        // Root excluded.
783        assert_eq!(row_names(&model.visible_rows()), vec!["a", "b"]);
784    }
785
786    #[test]
787    fn sort_by_size_orders_within_groups_and_descends() {
788        let mut fs = MemFs::default();
789        fs.dir_sized(
790            "/r",
791            vec![
792                ("small.txt", false, false, 10, 0),
793                ("big.txt", false, false, 900, 0),
794                ("mid.txt", false, false, 100, 0),
795            ],
796        );
797        let asc = SortOptions { key: SortKey::Size, ..SortOptions::default() };
798        let mut model = TreeModel::new("/r".into(), asc, false);
799        model.expand(Path::new("/r"), &fs).unwrap();
800        assert_eq!(row_names(&model.visible_rows()), vec!["small.txt", "mid.txt", "big.txt"]);
801
802        let desc = SortOptions {
803            key: SortKey::Size,
804            ascending: false,
805            ..SortOptions::default()
806        };
807        let mut model = TreeModel::new("/r".into(), desc, false);
808        model.expand(Path::new("/r"), &fs).unwrap();
809        assert_eq!(row_names(&model.visible_rows()), vec!["big.txt", "mid.txt", "small.txt"]);
810    }
811
812    #[test]
813    fn sort_by_modified_orders_by_time() {
814        let mut fs = MemFs::default();
815        fs.dir_sized(
816            "/r",
817            vec![
818                ("old.txt", false, false, 1, 100),
819                ("new.txt", false, false, 1, 300),
820                ("mid.txt", false, false, 1, 200),
821            ],
822        );
823        let opts = SortOptions { key: SortKey::Modified, ..SortOptions::default() };
824        let mut model = TreeModel::new("/r".into(), opts, false);
825        model.expand(Path::new("/r"), &fs).unwrap();
826        assert_eq!(row_names(&model.visible_rows()), vec!["old.txt", "mid.txt", "new.txt"]);
827    }
828
829    #[test]
830    fn sort_by_type_groups_by_extension() {
831        let mut fs = MemFs::default();
832        fs.dir(
833            "/r",
834            vec![
835                ("a.txt", false, false),
836                ("b.rs", false, false),
837                ("c.txt", false, false),
838                ("d.md", false, false),
839            ],
840        );
841        let opts = SortOptions { key: SortKey::Type, ..SortOptions::default() };
842        let mut model = TreeModel::new("/r".into(), opts, false);
843        model.expand(Path::new("/r"), &fs).unwrap();
844        // Extension groups: md, rs, txt; alphabetical within txt.
845        assert_eq!(row_names(&model.visible_rows()), vec!["d.md", "b.rs", "a.txt", "c.txt"]);
846    }
847
848    #[test]
849    fn reload_all_applies_hidden_toggle() {
850        let mut fs = MemFs::default();
851        fs.dir("/r", vec![(".hidden", false, false), ("shown", false, false)]);
852        let mut model = TreeModel::new("/r".into(), SortOptions::default(), false);
853        model.expand(Path::new("/r"), &fs).unwrap();
854        assert_eq!(row_names(&model.visible_rows()), vec!["shown"]);
855
856        model.set_show_hidden(true);
857        model.reload_all(&fs);
858        assert_eq!(row_names(&model.visible_rows()), vec![".hidden", "shown"]);
859    }
860
861    #[test]
862    fn hidden_entries_are_skipped_unless_enabled() {
863        let mut fs = MemFs::default();
864        fs.dir("/r", vec![(".git", true, false), (".env", false, false), ("src", true, false)]);
865
866        let mut hidden = TreeModel::new("/r".into(), SortOptions::default(), false);
867        hidden.expand(Path::new("/r"), &fs).unwrap();
868        // Root excluded.
869        assert_eq!(row_names(&hidden.visible_rows()), vec!["src"]);
870
871        let mut visible = TreeModel::new("/r".into(), SortOptions::default(), true);
872        visible.expand(Path::new("/r"), &fs).unwrap();
873        // Root excluded; dirs first: .git and src before the file .env.
874        assert_eq!(row_names(&visible.visible_rows()), vec![".git", "src", ".env"]);
875    }
876
877    #[test]
878    fn filter_narrows_visible_rows() {
879        let mut fs = MemFs::default();
880        fs.dir(
881            "/r",
882            vec![
883                ("main.rs", false, false),
884                ("README.md", false, false),
885                ("src", true, false),
886            ],
887        );
888        fs.dir("/r/src", vec![("main.rs", false, false), ("test.rs", false, false)]);
889
890        let mut model = TreeModel::new("/r".into(), SortOptions::default(), false);
891        model.expand(Path::new("/r"), &fs).unwrap();
892        model.expand(Path::new("/r/src"), &fs).unwrap();
893        model.set_filter("MAIN");
894        let rows = model.visible_rows();
895        // Root excluded; dirs first, so `src` (kept because a descendant matches)
896        // precedes the matching file; then src's own matching child.
897        assert_eq!(row_names(&rows), vec!["src", "main.rs", "main.rs"]);
898        assert!(rows.iter().all(|r| r.matches));
899        model.set_filter("");
900        // 5 items: main.rs, README.md, src, src/main.rs, src/test.rs (root not counted)
901        assert_eq!(row_names(&model.visible_rows()).len(), 5);
902    }
903
904    #[test]
905    fn filter_does_not_peek_into_collapsed_dirs() {
906        let mut fs = MemFs::default();
907        fs.dir("/r", vec![("src", true, false)]);
908        fs.dir("/r/src", vec![("needle.txt", false, false)]);
909
910        let mut model = TreeModel::new("/r".into(), SortOptions::default(), false);
911        model.expand(Path::new("/r"), &fs).unwrap();
912        model.set_filter("needle");
913        // src is not visible because its unloaded child is not peeked.
914        assert_eq!(row_names(&model.visible_rows()), Vec::<String>::new()); // src not matched, not peeked
915    }
916
917    #[test]
918    fn created_event_refreshes_loaded_parent() {
919        let mut fs = MemFs::default();
920        fs.dir("/r", Vec::new());
921        let mut model = TreeModel::new("/r".into(), SortOptions::default(), false);
922        model.expand(Path::new("/r"), &fs).unwrap();
923        assert!(model.visible_rows().iter().all(|r| r.name != "new.txt"));
924
925        fs.dir("/r", vec![("new.txt", false, false)]);
926        model.apply(&Change::Created { path: "/r/new.txt".into() }, &fs);
927        assert!(row_names(&model.visible_rows()).contains(&"new.txt".to_owned()));
928    }
929
930    #[test]
931    fn created_event_inside_no_loaded_parent_is_ignored_cheaply() {
932        let mut fs = MemFs::default();
933        fs.dir("/r", vec![("deep", true, false)]);
934        fs.dir("/r/deep", vec![("a.txt", false, false)]);
935        let mut model = TreeModel::new("/r".into(), SortOptions::default(), false);
936        model.expand(Path::new("/r"), &fs).unwrap();
937        // "deep" never expanded => not loaded, event is dropped.
938        fs.dir("/r/deep", vec![("a.txt", false, false), ("b.txt", false, false)]);
939        model.apply(&Change::Created { path: "/r/deep/b.txt".into() }, &fs);
940        let names = row_names(&model.visible_rows());
941        assert!(!names.contains(&"b.txt".to_owned()));
942    }
943
944    #[test]
945    fn removed_event_drops_subtree_cache() {
946        let mut fs = MemFs::default();
947        fs.dir("/r", vec![("a", true, false), ("keep.txt", false, false)]);
948        fs.dir("/r/a", vec![("x", false, false)]);
949        let mut model = TreeModel::new("/r".into(), SortOptions::default(), false);
950        model.expand(Path::new("/r"), &fs).unwrap();
951        model.expand(Path::new("/r/a"), &fs).unwrap();
952        // Root not shown; a, x, keep.txt remain.
953        assert_eq!(row_names(&model.visible_rows()), vec!["a", "x", "keep.txt"]);
954
955        fs.dir("/r", vec![("keep.txt", false, false)]);
956        model.apply(&Change::Removed { path: "/r/a".into() }, &fs);
957        assert_eq!(row_names(&model.visible_rows()), vec!["keep.txt"]);
958        assert!(!model.dirs.contains_key(Path::new("/r/a")), "subtree cache must be dropped");
959    }
960
961    #[test]
962    fn renamed_event_moves_entries() {
963        let mut fs = MemFs::default();
964        fs.dir("/r", vec![("old.txt", false, false), ("other", true, false)]);
965        let mut model = TreeModel::new("/r".into(), SortOptions::default(), false);
966        model.expand(Path::new("/r"), &fs).unwrap();
967
968        fs.dir("/r", vec![("other", true, false), ("new.txt", false, false)]);
969        model.apply(
970            &Change::Renamed { from: "/r/old.txt".into(), to: "/r/new.txt".into() },
971            &fs,
972        );
973        let names = row_names(&model.visible_rows());
974        assert!(!names.contains(&"old.txt".to_owned()));
975        assert!(names.contains(&"new.txt".to_owned()));
976    }
977
978    #[test]
979    fn rescan_reloads_even_after_repeated_cache() {
980        let mut fs = MemFs::default();
981        fs.dir("/r", vec![("a", true, false)]);
982        let mut model = TreeModel::new("/r".into(), SortOptions::default(), false);
983        model.expand(Path::new("/r"), &fs).unwrap();
984
985        // Simulate a torn event stream; rescan forces a full reload.
986        fs.dir("/r", vec![("a", true, false), ("new", false, false)]);
987        model.apply(&Change::Rescan, &fs);
988        assert!(row_names(&model.visible_rows()).contains(&"new".to_owned()));
989    }
990
991    #[test]
992    fn expand_error_does_not_panic_and_drops_cache_state() {
993        let fs = MemFs::default(); // /r missing entirely
994        let mut model = TreeModel::new("/r".into(), SortOptions::default(), false);
995        assert!(model.expand(Path::new("/r"), &fs).is_err());
996        // Root is not a row; no children to show. Renders to 0 rows.
997        assert_eq!(model.visible_rows().len(), 0);
998    }
999
1000    #[test]
1001    fn unreadable_expanded_dir_reports_error() {
1002        let mut fs = MemFs::default();
1003        fs.dir("/r", vec![("locked", true, false)]);
1004        let mut model = TreeModel::new("/r".into(), SortOptions::default(), false);
1005        model.expand(Path::new("/r"), &fs).unwrap();
1006        assert!(model.expand(Path::new("/r/locked"), &fs).is_err());
1007    }
1008
1009    #[test]
1010    fn is_hidden_rules() {
1011        assert!(is_hidden(".gitignore"));
1012        assert!(!is_hidden("."));
1013        assert!(!is_hidden(".."));
1014        assert!(!is_hidden("visible"));
1015    }
1016}