termi 0.1.0

A modal terminal code editor written in Rust
//! # Directory tree
//!
//! **Purpose:** a browsable view of a directory.
//!
//! **Responsibility:** keep a *flattened* list of visible entries — the shape a
//! list widget can render and index directly — and expand or collapse
//! directories on demand. Only expanded directories are read, so opening the
//! tree on a large project costs one `read_dir`, not a full walk.
//!
//! **Public API:** [`Tree`], [`Entry`].

use std::collections::HashSet;
use std::path::{Path, PathBuf};

/// One visible row of the tree.
#[derive(Debug, Clone)]
pub struct Entry {
    /// Full path on disk.
    pub path: PathBuf,
    /// File name, for display.
    pub name: String,
    /// Whether this is a directory.
    pub is_dir: bool,
    /// Whether an expanded directory.
    pub is_open: bool,
    /// Nesting level, used for indentation.
    pub depth: usize,
}

/// A directory rendered as a flat, indented list.
#[derive(Debug)]
pub struct Tree {
    root: PathBuf,
    entries: Vec<Entry>,
    expanded: HashSet<PathBuf>,
}

impl Tree {
    /// Build a tree rooted at `root`, with the root itself already expanded.
    #[must_use]
    pub fn new(root: PathBuf) -> Self {
        let mut tree = Self {
            root,
            entries: Vec::new(),
            expanded: HashSet::new(),
        };
        tree.expanded.insert(tree.root.clone());
        tree.refresh();
        tree
    }

    /// The directory being browsed.
    #[must_use]
    pub fn root(&self) -> &Path {
        &self.root
    }

    /// Visible rows, in display order.
    #[must_use]
    pub fn entries(&self) -> &[Entry] {
        &self.entries
    }

    /// Re-read every expanded directory.
    ///
    /// Called on open and whenever the tree may be stale; unexpanded
    /// directories are still not touched.
    pub fn refresh(&mut self) {
        self.entries.clear();
        let root = self.root.clone();
        self.collect(&root, 0);
    }

    /// Expand or collapse the directory at `index`, returning the file to open
    /// when the row is a file instead.
    pub fn activate(&mut self, index: usize) -> Option<PathBuf> {
        let entry = self.entries.get(index)?;
        if !entry.is_dir {
            return Some(entry.path.clone());
        }
        let path = entry.path.clone();
        if !self.expanded.remove(&path) {
            self.expanded.insert(path);
        }
        self.refresh();
        None
    }

    /// Read one directory and append its children, recursing into the ones that
    /// are expanded.
    ///
    /// An unreadable directory is skipped rather than reported: a permission
    /// error on one folder should not empty the whole panel.
    fn collect(&mut self, directory: &Path, depth: usize) {
        let Ok(read) = std::fs::read_dir(directory) else {
            return;
        };
        let mut children: Vec<Entry> = read
            .flatten()
            .filter_map(|item| {
                let path = item.path();
                let name = path.file_name()?.to_str()?.to_string();
                // Dotfiles are hidden; they are rarely what someone is browsing
                // for and they dominate a project root.
                if name.starts_with('.') {
                    return None;
                }
                let is_dir = item.file_type().is_ok_and(|kind| kind.is_dir());
                Some(Entry {
                    is_open: is_dir && self.expanded.contains(&path),
                    path,
                    name,
                    is_dir,
                    depth,
                })
            })
            .collect();

        // Directories first, then files, each alphabetically — the ordering
        // every file browser uses, and stable across platforms unlike `read_dir`.
        children.sort_by(|a, b| {
            b.is_dir
                .cmp(&a.is_dir)
                .then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
        });

        for child in children {
            let recurse = child.is_open.then(|| child.path.clone());
            self.entries.push(child);
            if let Some(path) = recurse {
                self.collect(&path, depth + 1);
            }
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    /// Build a small directory layout under the system temp directory.
    fn fixture(name: &str) -> PathBuf {
        let root = std::env::temp_dir().join(format!("termi-tree-{name}"));
        let _ = std::fs::remove_dir_all(&root);
        std::fs::create_dir_all(root.join("src")).expect("create fixture");
        std::fs::create_dir_all(root.join(".hidden")).expect("create fixture");
        std::fs::write(root.join("Cargo.toml"), "").expect("create fixture");
        std::fs::write(root.join("src/main.rs"), "").expect("create fixture");
        root
    }

    #[test]
    fn directories_sort_before_files() {
        let tree = Tree::new(fixture("sorting"));
        let names: Vec<&str> = tree.entries().iter().map(|e| e.name.as_str()).collect();
        assert_eq!(names, vec!["src", "Cargo.toml"]);
    }

    #[test]
    fn hidden_entries_are_skipped() {
        let tree = Tree::new(fixture("hidden"));
        assert!(tree.entries().iter().all(|e| e.name != ".hidden"));
    }

    #[test]
    fn expanding_a_directory_reveals_its_children() {
        let mut tree = Tree::new(fixture("expand"));
        assert_eq!(tree.entries().len(), 2);

        assert_eq!(tree.activate(0), None);
        let names: Vec<&str> = tree.entries().iter().map(|e| e.name.as_str()).collect();
        assert_eq!(names, vec!["src", "main.rs", "Cargo.toml"]);
        assert_eq!(tree.entries()[1].depth, 1);

        // Collapsing puts it back.
        assert_eq!(tree.activate(0), None);
        assert_eq!(tree.entries().len(), 2);
    }

    #[test]
    fn activating_a_file_returns_its_path() {
        let root = fixture("activate");
        let mut tree = Tree::new(root.clone());
        assert_eq!(tree.activate(1), Some(root.join("Cargo.toml")));
    }

    #[test]
    fn an_out_of_range_index_is_ignored() {
        let mut tree = Tree::new(fixture("range"));
        assert_eq!(tree.activate(99), None);
    }
}