oo-ide 0.0.4

∞ is a terminal IDE focused on low distraction, high usability.
Documentation
//! Compact grouped file list helpers

use std::path::Path;
use std::collections::HashSet;
use std::path::PathBuf;

use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::ListItem;

use crate::vcs::{StatusEntry, FileChange, FileChangeStatus};

/// Group consecutive entries by their parent directory (preserves input order).
fn group_by_parent<E, F>(entries: &[E], get_path: F) -> Vec<(Option<String>, Vec<usize>)>
where
    F: Fn(&E) -> &Path,
{
    let mut groups: Vec<(Option<String>, Vec<usize>)> = Vec::new();
    let mut current_parent: Option<String> = None;
    for (i, e) in entries.iter().enumerate() {
        let parent = get_path(e)
            .parent()
            .filter(|p| *p != Path::new(""))
            .map(|p| p.to_string_lossy().replace('\\', "/"));

        if parent != current_parent {
            groups.push((parent.clone(), vec![i]));
            current_parent = parent;
        } else if let Some(last) = groups.last_mut() {
            last.1.push(i);
        } else {
            groups.push((parent.clone(), vec![i]));
        }
    }
    groups
}

/// Build a compact grouped list of `StatusEntry` rows (used by Commit view).
/// Returns (items, mapping) where mapping maps the displayed row index to the
/// original `entries` index (None for header/placeholder rows).
pub fn build_tree_list_items_with_map_from_status(
    entries: &[StatusEntry],
    cursor: usize,
    active: bool,
    partial_paths: &HashSet<&PathBuf>,
) -> (Vec<ListItem<'static>>, Vec<Option<usize>>) {
    let groups = group_by_parent(entries, |e| &e.path);
    let mut items: Vec<ListItem<'static>> = Vec::new();
    let mut mapping: Vec<Option<usize>> = Vec::new();

    for (parent_opt, indices) in groups {
        if let Some(dir) = parent_opt.as_ref() {
            items.push(ListItem::new(Span::styled(
                format!("{}/", dir),
                Style::default().fg(Color::Blue).add_modifier(Modifier::BOLD),
            )));
            mapping.push(None);
        }

        for &i in &indices {
            let e = &entries[i];
            let is_cursor = i == cursor && active;
            let indent = if parent_opt.is_some() { "  " } else { "" };
            let name = e.path.file_name().and_then(|n| n.to_str()).unwrap_or("?");
            let is_partial = partial_paths.contains(&e.path);
            let indicator = if e.staged && is_partial {
                " ● (partial)"
            } else if e.staged {
                ""
            } else {
                ""
            };
            let label = format!("{}{}{}", indent, name, indicator);

            let style = if is_cursor {
                Style::default()
                    .fg(Color::Cyan)
                    .add_modifier(Modifier::REVERSED | Modifier::BOLD)
            } else if e.staged {
                Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)
            } else {
                Style::default().fg(Color::Gray)
            };

            items.push(ListItem::new(Span::styled(label, style)));
            mapping.push(Some(i));
        }
    }

    if items.is_empty() {
        let placeholder = if active { "(nothing staged)" } else { "(no changes)" };
        items.push(ListItem::new(Span::styled(
            placeholder,
            Style::default().fg(Color::DarkGray),
        )));
        mapping.push(None);
    }

    (items, mapping)
}

/// Build a compact grouped list of `FileChange` rows (used by History view).
/// Returns (items, mapping) where mapping maps display rows to entry indices.
pub fn build_tree_list_items_with_map_from_filechanges(
    entries: &[FileChange],
    cursor: usize,
    active: bool,
) -> (Vec<ListItem<'static>>, Vec<Option<usize>>) {
    let groups = group_by_parent(entries, |e| &e.path);
    let mut items: Vec<ListItem<'static>> = Vec::new();
    let mut mapping: Vec<Option<usize>> = Vec::new();

    for (parent_opt, indices) in groups {
        if let Some(dir) = parent_opt.as_ref() {
            items.push(ListItem::new(Span::styled(
                format!("{}/", dir),
                Style::default().fg(Color::Blue).add_modifier(Modifier::BOLD),
            )));
            mapping.push(None);
        }

        for &i in &indices {
            let e = &entries[i];
            let is_cursor = i == cursor && active;
            let indent = if parent_opt.is_some() { "  " } else { "" };
            let name = e.path.file_name().and_then(|n| n.to_str()).unwrap_or("?");

            // Choose simple indicator colors (theme-free). The caller may style
            // further if needed.
            let indicator_color = match e.status {
                FileChangeStatus::Added => Color::Green,
                FileChangeStatus::Deleted => Color::Red,
                FileChangeStatus::Modified => Color::Yellow,
                FileChangeStatus::Renamed => Color::Cyan,
            };

            let mut spans = Vec::new();
            spans.push(Span::styled(
                format!(" {} ", e.status.indicator()),
                Style::default().fg(indicator_color),
            ));

            let file_style = if is_cursor {
                Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)
            } else {
                Style::default().fg(Color::Gray)
            };

            spans.push(Span::styled(format!("{}{}", indent, name), file_style));

            items.push(ListItem::new(Line::from(spans)));
            mapping.push(Some(i));
        }
    }

    if items.is_empty() {
        items.push(ListItem::new(Span::styled(
            "(no files)",
            Style::default().fg(Color::DarkGray),
        )));
        mapping.push(None);
    }

    (items, mapping)
}

// Keep compatibility wrapper functions that return only items
#[allow(dead_code)]
pub fn build_tree_list_items_from_status(
    entries: &[StatusEntry],
    cursor: usize,
    active: bool,
    partial_paths: &HashSet<&PathBuf>,
) -> Vec<ListItem<'static>> {
    build_tree_list_items_with_map_from_status(entries, cursor, active, partial_paths).0
}

pub fn build_tree_list_items_from_filechanges(
    entries: &[FileChange],
    cursor: usize,
    active: bool,
) -> Vec<ListItem<'static>> {
    build_tree_list_items_with_map_from_filechanges(entries, cursor, active).0
}

/// Build a compact grouped list from a vector of (path, staged) pairs used in
/// the Commit diff tab. Returns (items, mapping) where mapping maps displayed
/// row -> original entry index.
pub fn build_tree_list_items_with_map_from_paths_staged(
    entries: &[(PathBuf, bool)],
    cursor: usize,
    active: bool,
    sel_bg: Color,
    sel_fg: Color,
    accent: Color,
    fg_dim: Color,
) -> (Vec<ListItem<'static>>, Vec<Option<usize>>) {
    // Reuse grouping by parent using a closure
    let mut groups: Vec<(Option<String>, Vec<usize>)> = Vec::new();
    let mut current_parent: Option<String> = None;
    for (i, e) in entries.iter().enumerate() {
        let parent = e.0
            .parent()
            .filter(|p| *p != Path::new(""))
            .map(|p| p.to_string_lossy().replace('\\', "/"));
        if parent != current_parent {
            groups.push((parent.clone(), vec![i]));
            current_parent = parent;
        } else if let Some(last) = groups.last_mut() {
            last.1.push(i);
        } else {
            groups.push((parent.clone(), vec![i]));
        }
    }

    let mut items: Vec<ListItem<'static>> = Vec::new();
    let mut mapping: Vec<Option<usize>> = Vec::new();

    for (parent_opt, indices) in groups {
        if let Some(dir) = parent_opt.as_ref() {
            items.push(ListItem::new(Span::styled(
                format!("{}/", dir),
                Style::default().fg(Color::Blue).add_modifier(Modifier::BOLD),
            )));
            mapping.push(None);
        }

        for &i in &indices {
            let (path, staged) = &entries[i];
            let is_cursor = i == cursor && active;
            let indent = if parent_opt.is_some() { "  " } else { "" };
            let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("?");
            let label = if *staged {
                format!("{}{}", indent, name)
            } else {
                format!("{}    {}", indent, name)
            };

            let style = if is_cursor {
                if active {
                    Style::default().fg(sel_fg).bg(sel_bg).add_modifier(Modifier::BOLD)
                } else {
                    Style::default().fg(accent).add_modifier(Modifier::BOLD)
                }
            } else if *staged {
                Style::default().fg(accent)
            } else {
                Style::default().fg(fg_dim)
            };

            items.push(ListItem::new(Span::styled(label, style)));
            mapping.push(Some(i));
        }
    }

    if items.is_empty() {
        items.push(ListItem::new(Span::styled(
            "(no changes)",
            Style::default().fg(Color::DarkGray),
        )));
        mapping.push(None);
    }

    (items, mapping)
}