eazygit 0.5.1

A fast TUI for Git with staging, conflicts, rebase, and palette-first UX
Documentation
use super::structs::{ThemeConfig, PartialThemeConfig, ROLES};
use super::colors::is_valid_color_str;
use super::builtins::load_named_theme;
use std::fs;
use std::collections::HashMap;
use std::path::Path;
use serde::Deserialize;
use tracing::warn;
use crate::config::settings::Settings;
use crate::config::structs::PartialConfig;

pub fn config_dir() -> Option<std::path::PathBuf> {
    dirs::config_dir().map(|p| p.join("eazygit"))
}

#[derive(Debug, Clone, Deserialize, Default)]
struct ExternalTheme {
    #[serde(default)]
    extend: Option<ExtendBlock>,
    #[serde(default)]
    palette: HashMap<String, String>,
    #[serde(default)]
    ui: HashMap<String, String>,
}

#[derive(Debug, Clone, Deserialize)]
struct ExtendBlock {
    base: String,
}

pub fn load_external_theme(path: &Path, name: &str) -> Option<ThemeConfig> {
    let raw = fs::read_to_string(path).ok()?;
    let parsed: ExternalTheme = match toml::from_str(&raw) {
        Ok(v) => v,
        Err(e) => {
            warn!("theme {} parse error: {}", name, e);
            return None;
        }
    };

    let base = if let Some(extend) = parsed.extend {
        load_named_theme(&extend.base)
    } else {
        ThemeConfig::default()
    };

    let palette = parsed.palette;
    if palette.is_empty() {
        warn!("theme {} has empty palette", name);
    }
    let ui_map = parsed.ui;

    let mut cfg = base.clone();
    for role in ROLES {
        let palette_key = ui_map.get(*role).cloned().unwrap_or_else(|| role.to_string());
        if let Some(val) = palette.get(&palette_key) {
            if is_valid_color_str(val) {
                cfg.set_role(role, val.clone());
            } else {
                warn!("theme {} invalid color '{}' for {}", name, val, role);
            }
        }
    }
    Some(cfg)
}

pub fn merge_partial(cfg: &mut ThemeConfig, other: PartialThemeConfig) {
    if let Some(v) = other.fg { cfg.fg = v; }
    if let Some(v) = other.bg { cfg.bg = v; }
    if let Some(v) = other.muted { cfg.muted = v; }
    if let Some(v) = other.accent { cfg.accent = v; }
    if let Some(v) = other.border { cfg.border = v; }
    if let Some(v) = other.border_focused { cfg.border_focused = v; }
    if let Some(v) = other.selection_bg { cfg.selection_bg = v; }
    if let Some(v) = other.selection_fg { cfg.selection_fg = v; }
    if let Some(v) = other.diff_add { cfg.diff_add = v; }
    if let Some(v) = other.diff_remove { cfg.diff_remove = v; }
    if let Some(v) = other.diff_context { cfg.diff_context = v; }
    if let Some(v) = other.diff_hunk { cfg.diff_hunk = v; }
    if let Some(v) = other.staged { cfg.staged = v; }
    if let Some(v) = other.unstaged { cfg.unstaged = v; }
    if let Some(v) = other.untracked { cfg.untracked = v; }
    if let Some(v) = other.header { cfg.header = v; }
    if let Some(v) = other.footer { cfg.footer = v; }
    if let Some(v) = other.error { cfg.error = v; }
    if let Some(v) = other.warning { cfg.warning = v; }
    if let Some(v) = other.success { cfg.success = v; }
}

impl ThemeConfig {
    pub fn set_role(&mut self, role: &str, value: String) {
        match role {
            "fg" => self.fg = value,
            "bg" => self.bg = value,
            "muted" => self.muted = value,
            "accent" => self.accent = value,
            "border" => self.border = value,
            "border_focused" => self.border_focused = value,
            "selection_bg" => self.selection_bg = value,
            "selection_fg" => self.selection_fg = value,
            "diff_add" => self.diff_add = value,
            "diff_remove" => self.diff_remove = value,
            "diff_context" => self.diff_context = value,
            "diff_hunk" => self.diff_hunk = value,
            "staged" => self.staged = value,
            "unstaged" => self.unstaged = value,
            "untracked" => self.untracked = value,
            "header" => self.header = value,
            "footer" => self.footer = value,
            "error" => self.error = value,
            "warning" => self.warning = value,
            "success" => self.success = value,
            _ => {}
        }
    }
}

pub fn load_theme_by_name(name: &str) -> ThemeConfig {
    // 1. Check built-ins
    let mut theme = load_named_theme(name);
    
    // 2. Check external directory (~/.config/eazygit/themes/*.toml)
    if let Some(config_dir) = config_dir() {
        let theme_path = config_dir.join("themes").join(format!("{}.toml", name));
        if theme_path.exists() {
             if let Some(external) = load_external_theme(&theme_path, name) {
                 theme = external;
             }
        }
    }
    
    theme
}

pub fn list_themes() -> Vec<String> {
    let mut themes = vec![
        "default_dark".to_string(),
        "default_light".to_string(),
        "dracula".to_string(),
        "gruvbox".to_string(),
        "monokai".to_string(),
        "nord".to_string(),
        "one_dark".to_string(),
        "solarized_dark".to_string(),
        "solarized_light".to_string(),
        "tokyo_night".to_string(),
    ];
    
    if let Some(config_dir) = config_dir() {
        let themes_dir = config_dir.join("themes");
        if themes_dir.exists() {
             if let Ok(entries) = fs::read_dir(themes_dir) {
                 for entry in entries.flatten() {
                     let path = entry.path();
                     if path.extension().map_or(false, |e| e == "toml") {
                         if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
                             if !themes.contains(&stem.to_string()) {
                                 themes.push(stem.to_string());
                             }
                         }
                     }
                 }
             }
        }
    }
    themes.sort();
    themes
}