oo-ide 0.0.3

∞ is a terminal IDE focused on low distraction, high usability.
Documentation
//! File-system tree widget.
//!
//! [`FileTreeView`] is a thin wrapper around [`super::tree::TreeWidget`]
//! specialised for `PathBuf` payloads.  It handles:
//!
//! - Building a [`TreeWidget<PathBuf>`] from a directory on disk (shallow —
//!   only the top-level entries are read at startup; sub-directories are
//!   loaded lazily when the user first expands them).
//! - Styling: directories in bold **blue**, files in grey, selected row with
//!   a dark-grey background.
//! - Expand/collapse icons `▸` / `▾`.
//! - `selected_file()` — returns `Some(PathBuf)` only for file leaves.

use std::{
    fs,
    path::{Path, PathBuf},
};

use ratatui::{
    Frame,
    buffer::Buffer,
    layout::Rect,
    style::{Color, Modifier, Style},
    text::{Line, Span},
    widgets::ListItem,
};

use crate::prelude::*;

use widgets::tree::{FlatNode, TreeAction, TreeNode, TreeWidget};

struct FileEntry {
    path: PathBuf,
    is_dir: bool,
}

/// Read one level of `dir`, returning shallow `TreeNode<PathBuf>` entries.
/// Directories are represented as `unloaded` nodes (children not yet read).
fn load_dir_shallow(dir: &Path) -> Vec<TreeNode<PathBuf>> {
    let Ok(rd) = fs::read_dir(dir) else {
        return vec![];
    };

    let mut entries: Vec<FileEntry> = rd
        .filter_map(|e| e.ok())
        .map(|e| e.path())
        .filter(|p| {
            p.file_name()
                .and_then(|n| n.to_str())
                .map(|n| !utils::fs::is_noise(n))
                .unwrap_or(false)
        })
        .map(|path| {
            let is_dir = path.is_dir();
            FileEntry { path, is_dir }
        })
        .collect();

    // Sort directories first, then alphabetically.
    entries.sort_by(|a, b| b.is_dir.cmp(&a.is_dir).then_with(|| a.path.cmp(&b.path)));

    entries
        .into_iter()
        .map(|entry| {
            if entry.is_dir {
                TreeNode::unloaded(entry.path)
            } else {
                TreeNode::leaf(entry.path)
            }
        })
        .collect()
}

/// Stateful file-system tree.  Build with [`FileTreeView::from_dir`], then
/// call the navigation methods and `render` each frame.
#[derive(Debug)]
pub struct FileTreeView {
    inner: TreeWidget<PathBuf>,
}

impl FileTreeView {
    /// Build a shallow tree rooted at `dir` (instant — no recursive walk).
    pub fn from_dir(dir: &Path) -> Self {
        let roots = load_dir_shallow(dir);
        let inner = TreeWidget::new(roots);
        Self { inner }
    }

    pub fn key_up(&mut self) {
        self.inner.move_up();
    }
    pub fn key_down(&mut self) {
        self.inner.move_down();
    }
    pub fn key_left(&mut self) {
        self.inner.collapse_or_leave();
    }

    /// Move right / expand the selected node.
    ///
    /// Returns `Some(dir_path)` when the selected directory has not been
    /// loaded yet — the caller must read the directory and call
    /// [`Self::inject_children`].  Returns `None` when the action was handled
    /// immediately (already-loaded directory or file).
    pub fn key_right(&mut self) -> Option<PathBuf> {
        match self.inner.expand_or_enter() {
            TreeAction::NeedsLoad => self
                .inner
                .selected()
                .map(|flat| flat.node.data.clone()),
            TreeAction::Done => None,
        }
    }

    /// Toggle expand / collapse of the selected directory.
    ///
    /// Returns `Some(dir_path)` when the directory needs to be loaded (same
    /// semantics as [`Self::key_right`]).
    pub fn toggle_selected(&mut self) -> Option<PathBuf> {
        match self.inner.toggle_selected() {
            TreeAction::NeedsLoad => self
                .inner
                .selected()
                .map(|flat| flat.node.data.clone()),
            TreeAction::Done => None,
        }
    }

    /// Inject lazily-loaded children for the directory at `abs_path`.
    ///
    /// Performs a DFS to find the matching `NotLoaded` node and delegates to
    /// [`TreeWidget::inject_children`].
    pub fn inject_children(&mut self, abs_path: &Path, entries: Vec<(PathBuf, bool)>) {
        log::debug!("file_tree.inject_children: path={} entries={}", abs_path.display(), entries.len());
        let children: Vec<TreeNode<PathBuf>> = entries
            .into_iter()
            .map(|(p, is_dir)| {
                if is_dir {
                    TreeNode::unloaded(p)
                } else {
                    TreeNode::leaf(p)
                }
            })
            .collect();

        // Walk roots to find the matching node's index path.
        if let Some(idx_path) = find_path(self.inner.roots(), abs_path) {
            log::debug!("file_tree.inject_children: found idx_path={:?} for {}", idx_path, abs_path.display());
            self.inner.inject_children(&idx_path, children);
            self.inner.sync_flat_idx();
            log::debug!("file_tree.inject_children: injected children, flat_len={:?}", ());
        } else {
            log::debug!("file_tree.inject_children: no matching node found for {}", abs_path.display());
        }
    }

    /// Programmatically set the cursor to `abs_path` if it exists in the tree.
    pub fn set_cursor_to(&mut self, abs_path: &Path) -> bool {
        log::debug!("file_tree.set_cursor_to: trying {}", abs_path.display());
        if let Some(idx_path) = find_path(self.inner.roots(), abs_path) {
            log::debug!("file_tree.set_cursor_to: found idx_path={:?}", idx_path);
            self.inner.set_cursor_path(&idx_path);
            return true;
        }
        log::debug!("file_tree.set_cursor_to: not found {}", abs_path.display());
        false
    }

    /// Returns the absolute path of the selected **file**, or `None` when a
    /// directory or an empty tree is selected.
    pub fn selected_file(&self) -> Option<PathBuf> {
        let flat = self.inner.selected()?;
        if flat.node.is_leaf() {
            Some(flat.node.data.clone())
        } else {
            None
        }
    }

    #[allow(clippy::too_many_arguments)]
    pub fn render_into(
        &self,
        area: Rect,
        buf: &mut Buffer,
        is_active: bool,
        pane_bg: Color,
        sel_bg: Color,
        sel_fg: Color,
        dir_fg: Color,
        fg_dim: Color,
    ) {
        self.inner.render(area, buf, |flat, selected| {
            render_node(flat, selected, is_active, pane_bg, sel_bg, sel_fg, dir_fg, fg_dim)
        });
    }

    #[allow(clippy::too_many_arguments)]
    pub fn render(
        &self,
        frame: &mut Frame,
        area: Rect,
        is_active: bool,
        pane_bg: Color,
        sel_bg: Color,
        sel_fg: Color,
        dir_fg: Color,
        fg_dim: Color,
    ) {
        self.render_into(area, frame.buffer_mut(), is_active, pane_bg, sel_bg, sel_fg, dir_fg, fg_dim);
    }
}


/// Render a relative `PathBuf` as a coloured list item.
///
/// The directory portion is shown in `fg_dim`; the filename in `sel_fg` /
/// `Color::Gray` depending on selection state.  When `dirty` is true a
/// `[+]` indicator is appended in amber to signal unsaved modifications.
#[allow(clippy::too_many_arguments)]
pub(crate) fn path_list_item(
    path: &Path,
    selected: bool,
    dirty: bool,
    pane_bg: Color,
    sel_bg: Color,
    sel_fg: Color,
    fg_dim: Color,
    max_width: usize,
) -> ListItem<'static> {
    use ratatui::style::Style as RStyle;

    let bg = if selected { sel_bg } else { pane_bg };

    // Base style with background to avoid repetition
    let base_bg = RStyle::default().bg(bg);

    // Build style config for the formatter using caller colors and background
    let filename_style = base_bg.fg(if selected { sel_fg } else { Color::Gray })
        .add_modifier(if selected { Modifier::BOLD } else { Modifier::empty() });

    let path_style = base_bg.fg(if selected { sel_fg } else { fg_dim });
    let dim_style = base_bg.fg(fg_dim).add_modifier(Modifier::DIM);
    let sep_style = base_bg.fg(fg_dim).add_modifier(Modifier::DIM);

    let cfg = crate::path_format::StyleConfig {
        filename: filename_style,
        path: path_style,
        dim: dim_style,
        separator: sep_style,
    };

    let mut spans = crate::path_format::format_path_spans_with_min_tail(path, max_width, cfg, 2);

    if dirty {
        spans.push(Span::styled(" [+]".to_string(), base_bg.fg(Color::Yellow)));
    }

    ListItem::new(Line::from(spans))
}

/// DFS search: return the index path `[root_idx, child_idx, ...]` for the
/// node whose `data == target`.  Only visits nodes that could contain the
/// target (ignores already-loaded dirs whose data doesn't match).
fn find_path(roots: &[TreeNode<PathBuf>], target: &Path) -> Option<Vec<usize>> {
    for (i, root) in roots.iter().enumerate() {
        if let Some(mut path) = find_in_node(root, target) {
            path.insert(0, i);
            return Some(path);
        }
    }
    None
}

fn find_in_node(node: &TreeNode<PathBuf>, target: &Path) -> Option<Vec<usize>> {
    if node.data == target {
        return Some(vec![]);
    }
    if let widgets::tree::ChildrenState::Loaded(children) = &node.children {
        for (i, child) in children.iter().enumerate() {
            if let Some(mut path) = find_in_node(child, target) {
                path.insert(0, i);
                return Some(path);
            }
        }
    }
    None
}

#[allow(clippy::too_many_arguments)]
fn render_node(
    flat: &FlatNode<'_, PathBuf>,
    selected: bool,
    is_active: bool,
    pane_bg: Color,
    sel_bg: Color,
    sel_fg: Color,
    dir_fg: Color,
    fg_dim: Color,
) -> Line<'static> {
    let node = flat.node;

    let indent = "  ".repeat(flat.depth);

    let icon: &str = if node.is_leaf() {
        "  "
    } else if node.expanded {
        ""
    } else {
        ""
    };

    let bg = if selected && is_active { sel_bg } else { pane_bg };

    // Styles for prefix and path pieces (filename/path/separator)
    let prefix_style = Style::default()
        .fg(if node.is_leaf() { fg_dim } else { dir_fg })
        .bg(bg);

    // Build a style for the filename. No need to compute available width
    // here — tree rows show only the filename because hierarchy is implied
    // by the indent and expand/collapse icon.
    let indent_icon = format!("{}{}", indent, icon);

    let filename_style = Style::default()
        .bg(bg)
        .fg(if selected && is_active { sel_fg } else { Color::Gray })
        .add_modifier(if selected && is_active { Modifier::BOLD } else { Modifier::empty() });

    // Tree view: show only the filename — the hierarchy is already indicated
    // by the indent and expand/collapse icon. Avoid showing the full directory
    // path here to reduce redundancy; lists (history / search results) still
    // use path_list_item which shows filename — path.
    let filename_text = node
        .data
        .file_name()
        .map(|n| n.to_string_lossy().into_owned())
        .unwrap_or_else(|| node.data.display().to_string());

    let out: Vec<Span<'static>> = vec![
      Span::styled(indent_icon, prefix_style),
      Span::styled(filename_text, filename_style)];

    Line::from(out)
}