muffintui 0.1.14

A terminal workspace that combines a file tree, editor, shell, and embedded Codex pane
Documentation
use std::{
    collections::HashSet,
    fs, io,
    path::{Path, PathBuf},
    process::Command,
};

#[derive(Clone)]
pub struct FileEntry {
    pub path: PathBuf,
    pub display: String,
    pub is_dir: bool,
    pub depth: usize,
    pub is_updated: bool,
}

pub fn collect_visible_file_entries(
    root: &Path,
    expanded_dirs: &HashSet<PathBuf>,
) -> io::Result<Vec<FileEntry>> {
    let updated_paths = collect_updated_paths(root).unwrap_or_default();
    let mut out = Vec::new();
    collect_visible_file_entries_recursive(root, root, expanded_dirs, &updated_paths, 0, &mut out)?;
    Ok(out)
}

pub fn collapse_directory(path: &Path, expanded_dirs: &mut HashSet<PathBuf>) {
    expanded_dirs.retain(|candidate| candidate != path && !candidate.starts_with(path));
}

fn collect_visible_file_entries_recursive(
    root: &Path,
    dir: &Path,
    expanded_dirs: &HashSet<PathBuf>,
    updated_paths: &HashSet<PathBuf>,
    depth: usize,
    out: &mut Vec<FileEntry>,
) -> io::Result<()> {
    let mut entries = fs::read_dir(dir)?.collect::<Result<Vec<_>, io::Error>>()?;
    entries.sort_by(|a, b| {
        let a_is_dir = a.file_type().map(|f| f.is_dir()).unwrap_or(false);
        let b_is_dir = b.file_type().map(|f| f.is_dir()).unwrap_or(false);
        b_is_dir
            .cmp(&a_is_dir)
            .then_with(|| a.file_name().cmp(&b.file_name()))
    });

    for entry in entries {
        let path = entry.path();
        let file_name = entry.file_name().to_string_lossy().to_string();
        if file_name == ".git" || file_name == "target" {
            continue;
        }

        let is_dir = entry.file_type()?.is_dir();
        let is_updated = if is_dir {
            updated_paths
                .iter()
                .any(|candidate| candidate.starts_with(&path))
        } else {
            updated_paths.contains(&path)
        };
        let icon = if is_dir {
            if expanded_dirs.contains(&path) {
                ""
            } else {
                ""
            }
        } else {
            " "
        };
        let display = if is_dir {
            format!("{icon} {file_name}/")
        } else {
            format!("{icon} {file_name}")
        };

        out.push(FileEntry {
            path: path.clone(),
            display,
            is_dir,
            depth,
            is_updated,
        });

        if is_dir && expanded_dirs.contains(&path) {
            collect_visible_file_entries_recursive(
                root,
                &path,
                expanded_dirs,
                updated_paths,
                depth + 1,
                out,
            )?;
        }
    }

    if depth == 0 && out.is_empty() {
        out.push(FileEntry {
            path: root.to_path_buf(),
            display: "(empty)".to_string(),
            is_dir: false,
            depth: 0,
            is_updated: false,
        });
    }

    Ok(())
}

fn collect_updated_paths(root: &Path) -> io::Result<HashSet<PathBuf>> {
    let output = Command::new("git")
        .arg("status")
        .arg("--porcelain")
        .arg("--untracked-files=normal")
        .current_dir(root)
        .output()?;

    if !output.status.success() {
        return Ok(HashSet::new());
    }

    let mut updated = HashSet::new();
    for line in String::from_utf8_lossy(&output.stdout).lines() {
        if line.len() < 4 {
            continue;
        }

        let raw_path = &line[3..];
        let path_str = raw_path
            .rsplit_once(" -> ")
            .map(|(_, path)| path)
            .unwrap_or(raw_path)
            .trim_matches('"');

        if path_str.is_empty() {
            continue;
        }

        updated.insert(root.join(path_str));
    }

    Ok(updated)
}