eazygit 0.5.1

A fast TUI for Git with staging, conflicts, rebase, and palette-first UX
Documentation
use crate::app::AppState;
use crate::ui::style::{self, Emphasis};
use crate::palette::command::CommandCategory;
use crate::palette::filter::ScoredCommand;
use crate::config::ThemeConfig;
use ratatui::text::Span;
use ratatui::style::Modifier;
use std::collections::HashMap;

pub fn parse_category_filter(input: &str) -> (String, Option<CommandCategory>) {
    if let Some(category_str) = input.strip_prefix("@") {
        // Find where category name ends (space or end of string)
        let category_end = category_str
            .char_indices()
            .find(|(_, c)| c.is_whitespace())
            .map(|(i, _)| i)
            .unwrap_or(category_str.len());
        
        let category_name = &category_str[..category_end];
        let remaining = category_str[category_end..].trim();
        
        let category = match category_name.to_lowercase().as_str() {
            "git" | "gitoperations" => Some(CommandCategory::GitOperations),
            "staging" => Some(CommandCategory::Staging),
            "remote" => Some(CommandCategory::Remote),
            "rebase" => Some(CommandCategory::Rebase),
            "reset" => Some(CommandCategory::Reset),
            "log" => Some(CommandCategory::Log),
            "navigation" | "nav" => Some(CommandCategory::Navigation),
            "config" | "configuration" => Some(CommandCategory::Configuration),
            "conflicts" | "conflict" => Some(CommandCategory::Conflicts),
            "pr" => Some(CommandCategory::Pr),
            "autofetch" | "fetch" => Some(CommandCategory::AutoFetch),
            "file" | "fileoperations" => Some(CommandCategory::FileOperations),
            "merge" | "mergerebase" => Some(CommandCategory::MergeRebase),
            _ => None,
        };
        
        if category.is_some() {
            return (remaining.to_string(), category);
        }
    }
    (input.to_string(), None)
}

pub fn group_by_category<'a>(
    commands: &'a [ScoredCommand],
    category_filter: Option<CommandCategory>,
) -> HashMap<CommandCategory, Vec<&'a ScoredCommand>> {
    let mut grouped: HashMap<CommandCategory, Vec<&ScoredCommand>> = HashMap::new();
    
    for cmd in commands {
        // If category filter is set, only include matching categories
        if let Some(filter) = category_filter {
            if cmd.command.category != filter {
                continue;
            }
        }
        
        grouped
            .entry(cmd.command.category)
            .or_insert_with(Vec::new)
            .push(cmd);
    }
    
    // Sort commands within each category by score
    for commands_in_category in grouped.values_mut() {
        commands_in_category.sort_by(|a, b| b.score.cmp(&a.score));
    }
    
    grouped
}

pub fn get_custom_commands(state: &AppState, query: &str) -> Vec<(String, String)> {
    let query_lower = query.to_lowercase();
    state.custom_commands
        .iter()
        .filter(|(name, _)| {
            query.is_empty() || name.to_lowercase().contains(&query_lower)
        })
        .map(|(name, cmd)| (name.clone(), cmd.clone()))
        .collect()
}

pub fn category_color(category: &CommandCategory, theme: &ThemeConfig) -> ratatui::style::Style {
    use CommandCategory::*;
    let color = match category {
        GitOperations => theme.diff_add_color(),
        Staging => theme.staged_color(),
        Remote => theme.diff_context_color(),
        Rebase | Reset => theme.warning_color(),
        Log => theme.muted_color(),
        Navigation => theme.border_focused_color(),
        Configuration => theme.footer_color(),
        Conflicts => theme.error_color(),
        AutoFetch => theme.diff_add_color(),
        Pr => theme.accent_color(),
        FileOperations => theme.diff_context_color(),
        MergeRebase => theme.warning_color(),
    };
    ratatui::style::Style::default().fg(color)
}

pub fn category_abbreviation(category: &CommandCategory) -> &'static str {
    use CommandCategory::*;
    match category {
        GitOperations => "Git",
        Staging => "Stg",
        Remote => "Rem",
        Rebase => "Reb",
        Reset => "Rst",
        Log => "Log",
        Navigation => "Nav",
        Configuration => "Cfg",
        Conflicts => "Cnf",
        AutoFetch => "Fch",
        Pr => "PR",
        FileOperations => "File",
        MergeRebase => "Merge",
    }
}

pub fn highlight_matches(
    text: &str,
    match_ranges: &[(usize, usize)],
    state: &AppState,
) -> Vec<Span<'static>> {
    if match_ranges.is_empty() {
        return vec![Span::styled(
            text.to_string(),
            style::text(&state.theme, Emphasis::Normal),
        )];
    }
    
    let mut spans = Vec::new();
    let mut last_idx = 0;
    
    // Sort ranges by start position
    let mut sorted_ranges = match_ranges.to_vec();
    sorted_ranges.sort_by_key(|r| r.0);
    
    for (start, end) in sorted_ranges {
        // Defensive: ensure ranges are valid and within bounds
        let start = start.min(text.len());
        let end = end.min(text.len());
        
        // Skip invalid ranges (start >= end or out of bounds)
        if start >= end || start > text.len() || end > text.len() {
            continue;
        }
        
        // Add text before match
        if start > last_idx {
            spans.push(Span::styled(
                text[last_idx..start].to_string(),
                style::text(&state.theme, Emphasis::Normal),
            ));
        }
        
        // Add highlighted match
        spans.push(Span::styled(
            text[start..end].to_string(),
            style::text(&state.theme, Emphasis::Normal)
                .add_modifier(Modifier::BOLD)
                .add_modifier(Modifier::UNDERLINED),
        ));
        
        last_idx = end;
    }
    
    // Add remaining text
    if last_idx < text.len() {
        spans.push(Span::styled(
            text[last_idx..].to_string(),
            style::text(&state.theme, Emphasis::Normal),
        ));
    }
    
    spans
}