lla 0.3.9

Blazing Fast and highly customizable ls Replacement with Superpowers
use super::FileFormatter;
use crate::error::Result;
use crate::plugin::PluginManager;
use crate::theme::{self, ColorValue};
use crate::utils::color::{self, colorize_file_name, colorize_file_name_with_icon};
use crate::utils::icons::format_with_icon;
use colored::*;
use console::strip_ansi_codes;
use lla_plugin_interface::proto::DecoratedEntry;
use std::collections::HashMap;
use std::path::Path;
use std::process::Command;
use unicode_width::UnicodeWidthStr;

pub struct GitFormatter {
    pub show_icons: bool,
}

impl GitFormatter {
    pub fn new(show_icons: bool) -> Self {
        Self { show_icons }
    }

    fn strip_ansi(s: &str) -> String {
        String::from_utf8(strip_ansi_escapes::strip(s).unwrap_or_default()).unwrap_or_default()
    }

    fn get_theme_color(value: &ColorValue) -> Color {
        theme::color_value_to_color(value)
    }
}
#[derive(Debug, Default)]
struct GitInfo {
    branch: String,
    ahead: usize,
    behind: usize,
}

impl GitFormatter {
    fn get_git_info(path: &Path) -> Option<GitInfo> {
        let status = Command::new("git")
            .arg("rev-parse")
            .arg("--is-inside-work-tree")
            .current_dir(path)
            .output()
            .ok()?;

        if !status.status.success() {
            return None;
        }

        let mut info = GitInfo::default();

        if let Ok(output) = Command::new("git")
            .args(["status", "-b", "--porcelain=v2"])
            .current_dir(path)
            .output()
        {
            for line in String::from_utf8_lossy(&output.stdout).lines() {
                if line.starts_with("# branch.head ") {
                    info.branch = line.split_whitespace().nth(2)?.to_string();
                } else if line.starts_with("# branch.ab ") {
                    let parts: Vec<&str> = line.split_whitespace().collect();
                    if parts.len() >= 4 {
                        info.ahead = parts[2].trim_start_matches('+').parse().unwrap_or(0);
                        info.behind = parts[3].trim_start_matches('-').parse().unwrap_or(0);
                    }
                }
            }
        }

        Some(info)
    }

    fn format_git_status(status: &str) -> (String, String) {
        let theme = color::get_theme();
        let staged_color = Self::get_theme_color(&theme.colors.executable);
        let modified_color = Self::get_theme_color(&ColorValue::Named("yellow".to_string()));
        let deleted_color = Self::get_theme_color(&ColorValue::Named("red".to_string()));
        let renamed_color = Self::get_theme_color(&theme.colors.symlink);
        let untracked_color = Self::get_theme_color(&ColorValue::Named("bright black".to_string()));

        let status_str = match status {
            "M." => "[staged]".to_string().color(staged_color),
            "A." => "[added]".to_string().color(staged_color),
            "D." => "[deleted]".to_string().color(deleted_color),
            "R." => "[renamed]".to_string().color(renamed_color),
            "C." => "[copied]".to_string().color(renamed_color),

            ".M" => "[modified]".to_string().color(modified_color),
            ".D" => "[deleted]".to_string().color(deleted_color),

            "MM" => "[modified*]".to_string().color(modified_color),
            "AM" => "[added+]".to_string().color(staged_color),
            "DM" => "[deleted*]".to_string().color(deleted_color),

            "UU" => "[conflict]".to_string().color(deleted_color),

            "??" => "[untracked]".to_string().color(untracked_color),
            "!!" => "[ignored]".to_string().color(untracked_color),
            "." => "[unchanged]".to_string().normal(),

            s if s.starts_with('M') => format!("[modified+{}]", &s[1..]).color(modified_color),
            s if s.starts_with('A') => format!("[added+{}]", &s[1..]).color(staged_color),
            s if s.starts_with('D') => format!("[deleted+{}]", &s[1..]).color(deleted_color),
            s if s.starts_with('R') => format!("[renamed+{}]", &s[1..]).color(renamed_color),
            s if s.starts_with('C') => format!("[copied+{}]", &s[1..]).color(renamed_color),
            s => format!("[{}]", s).normal(),
        };

        (status_str.to_string(), "".to_string())
    }

    fn get_git_status_map(workspace_root: &Path) -> HashMap<String, String> {
        let mut status_map = HashMap::new();

        if let Ok(output) = Command::new("git")
            .args(["status", "--porcelain=v2", "--untracked-files=all"])
            .current_dir(workspace_root)
            .output()
        {
            for line in String::from_utf8_lossy(&output.stdout).lines() {
                if line.is_empty() || line.starts_with('#') {
                    continue;
                }

                let parts: Vec<&str> = line.split_whitespace().collect();
                if parts.is_empty() {
                    continue;
                }

                match parts[0] {
                    "1" | "2" if parts.len() >= 9 => {
                        let xy = parts[1];
                        let path = parts[8];
                        status_map.insert(path.to_string(), xy.to_string());
                    }
                    "?" if parts.len() >= 2 => {
                        status_map.insert(parts[1].to_string(), "??".to_string());
                    }
                    "!" if parts.len() >= 2 => {
                        status_map.insert(parts[1].to_string(), "!!".to_string());
                    }
                    _ => {}
                }
            }
        }

        if let Ok(output) = Command::new("git")
            .args(["ls-files"])
            .current_dir(workspace_root)
            .output()
        {
            for line in String::from_utf8_lossy(&output.stdout).lines() {
                status_map
                    .entry(line.to_string())
                    .or_insert_with(|| ".".to_string());
            }
        }

        status_map
    }

    fn get_last_commit_info(path: &Path, file_path: &Path) -> Option<(String, String, String)> {
        let output = Command::new("git")
            .args([
                "log",
                "-1",
                "--format=%h|%s|%cr|%an",
                "--",
                file_path.to_str()?,
            ])
            .current_dir(path)
            .output()
            .ok()?;

        if !output.status.success() {
            return None;
        }

        let log = String::from_utf8_lossy(&output.stdout);
        let parts: Vec<&str> = log.trim().split('|').collect();
        if parts.len() >= 4 {
            Some((
                parts[0].to_string(),
                parts[2].to_string(),
                parts[3].to_string(),
            ))
        } else {
            None
        }
    }
}

impl FileFormatter for GitFormatter {
    fn format_files(
        &self,
        files: &[DecoratedEntry],
        plugin_manager: &mut PluginManager,
        _depth: Option<usize>,
    ) -> Result<String> {
        if files.is_empty() {
            return Ok(String::new());
        }

        let theme = color::get_theme();
        let branch_color = Self::get_theme_color(&theme.colors.executable);
        let ahead_color = Self::get_theme_color(&ColorValue::Named("yellow".to_string()));
        let behind_color = Self::get_theme_color(&ColorValue::Named("red".to_string()));
        let separator_color = Self::get_theme_color(&ColorValue::Named("bright black".to_string()));
        let hash_color = Self::get_theme_color(&theme.colors.symlink);
        let time_color = Self::get_theme_color(&ColorValue::Named("bright black".to_string()));
        let author_color = Self::get_theme_color(&theme.colors.user);

        let workspace_root = Path::new(&files[0].path)
            .ancestors()
            .find(|p| p.join(".git").exists())
            .unwrap_or(Path::new("."));

        let git_info = match GitFormatter::get_git_info(workspace_root) {
            Some(info) => info,
            None => return Ok("Not a git repository".red().to_string()),
        };

        let status_map = GitFormatter::get_git_status_map(workspace_root);
        let mut max_name_width: usize = 0;
        let mut max_hash_width: usize = 0;
        let mut max_time_width: usize = 0;
        let mut max_author_width: usize = 0;

        for file in files {
            let path = Path::new(&file.path);
            let name = colorize_file_name(path);
            let name_with_icon = colorize_file_name_with_icon(
                path,
                format_with_icon(path, name.to_string(), self.show_icons),
            );
            let commit_info = GitFormatter::get_last_commit_info(workspace_root, path)
                .unwrap_or_else(|| ("-".to_string(), "never".to_string(), "-".to_string()));

            max_name_width = max_name_width.max(Self::strip_ansi(&name_with_icon).width());
            max_hash_width = max_hash_width.max(commit_info.0.len());
            max_time_width = max_time_width.max(commit_info.1.len());
            max_author_width = max_author_width.max(commit_info.2.len());
        }

        let mut output = format!(
            "\n{} {}{}{}\n{}\n",
            "".color(branch_color),
            git_info.branch.color(branch_color).bold(),
            if git_info.ahead > 0 {
                format!("{}", git_info.ahead).color(ahead_color)
            } else {
                "".into()
            },
            if git_info.behind > 0 {
                format!("{}", git_info.behind).color(behind_color)
            } else {
                "".into()
            },
            "".repeat(40).color(separator_color)
        );

        output.push_str(&format!(
            "{}  {}  {}  {}  {}\n",
            "Name".bold(),
            "Commit".bold(),
            "Time".bold(),
            "Author".bold(),
            "Status".bold()
        ));
        output.push_str(
            &""
                .repeat(max_name_width + max_hash_width + max_time_width + max_author_width + 40)
                .bright_black(),
        );
        output.push('\n');

        let mut entries = Vec::new();
        for file in files {
            let path = Path::new(&file.path);
            let name = colorize_file_name(path);
            let name_with_icon = colorize_file_name_with_icon(
                path,
                format_with_icon(path, name.to_string(), self.show_icons),
            );

            let relative_path = path.strip_prefix(workspace_root).unwrap_or(path);
            let relative_path_str = relative_path.to_string_lossy();

            let (status_str, _) = status_map
                .get(relative_path_str.as_ref())
                .map(|s| GitFormatter::format_git_status(s))
                .unwrap_or_else(|| ("".to_string(), "".to_string()));

            let commit_info = GitFormatter::get_last_commit_info(workspace_root, relative_path)
                .unwrap_or_else(|| ("-".to_string(), "never".to_string(), "-".to_string()));

            let plugin_fields = plugin_manager.format_fields(file, "git").join(" ");

            entries.push((name_with_icon, commit_info, status_str, plugin_fields));
        }

        for (name, commit_info, status, plugin_fields) in entries {
            let name_width = strip_ansi_codes(&name).width();
            let name_padding = " ".repeat(max_name_width.saturating_sub(name_width) as usize);

            let hash_padding = " ".repeat(max_hash_width.saturating_sub(commit_info.0.len()));
            let time_padding = " ".repeat(max_time_width.saturating_sub(commit_info.1.len()));

            output.push_str(&format!(
                "{}{}  @{}{}  {}{}  {}{}{}",
                name,
                name_padding,
                commit_info.0.color(hash_color),
                hash_padding,
                commit_info.1.color(time_color),
                time_padding,
                if commit_info.2 != "-" {
                    format!("by {} ", commit_info.2.color(author_color))
                } else {
                    "".into()
                },
                status,
                if plugin_fields.is_empty() {
                    String::new()
                } else {
                    format!(" {}", plugin_fields)
                }
            ));
            output.push('\n');
        }

        Ok(output)
    }
}