eazygit 0.5.1

A fast TUI for Git with staging, conflicts, rebase, and palette-first UX
Documentation
use super::filter::{FuzzyMatcher, ScoredCommand};
use super::registry::CommandRegistry;
use super::command::CommandCategory;
use crate::app::{Action, AppState};
use crate::errors::ComponentError;
use crate::input::InputEvent;
use crossterm::event::{KeyCode, KeyEventKind};
use std::collections::HashMap;

pub struct PaletteHandler {
    registry: &'static CommandRegistry,
    matcher: FuzzyMatcher,
}

impl PaletteHandler {
    pub fn new() -> Self {
        Self {
            registry: CommandRegistry::instance(),
            matcher: FuzzyMatcher::new(),
        }
    }
    
    pub fn handle_event(
        &mut self,
        event: InputEvent,
        state: &AppState,
    ) -> Result<Option<Action>, ComponentError> {
        if let InputEvent::Key(key) = &event {
            if key.kind != KeyEventKind::Press {
                return Ok(None);
            }
            
            use KeyCode::*;
            return Ok(match key.code {
                Esc => Some(Action::HidePalette),
                Enter => {
                    let filtered = self.filter_commands(&state.palette_input, state);
                    let custom_commands = self.filter_custom_commands(&state.palette_input, state);
                    let total_items = filtered.len() + custom_commands.len();
                    let max_idx = total_items.saturating_sub(1);
                    let selected = state.palette_selected.min(max_idx);
                    
                    // Check if selected item is a custom command
                    if selected >= filtered.len() {
                        let custom_idx = selected - filtered.len();
                        if let Some((name, _)) = custom_commands.get(custom_idx) {
                            return Ok(Some(Action::ExecuteCustomCommand(name.clone())));
                        }
                    }
                    
                    // When palette is grouped (empty query), map visual index to original command index
                    let query = state.palette_input.trim();
                    let is_grouped = query.is_empty() && !state.palette_input.starts_with("@");
                    let command_idx = if is_grouped {
                        // Map visual selected index to original command index when grouped
                        let (_, category_filter) = Self::parse_category_filter(&state.palette_input);
                        let grouped = Self::group_by_category(&filtered, category_filter);
                        Self::map_grouped_index_to_original(&grouped, selected, &filtered)
                    } else {
                        // When not grouped, use selected index directly
                        selected
                    };
                    
                    // Regular command
                    if let Some(scored) = filtered.get(command_idx.min(filtered.len().saturating_sub(1))) {
                        if let Some(action) = scored.command.create_action(state) {
                            Some(action)
                        } else {
                            Some(Action::HidePalette)
                        }
                    } else {
                        Some(Action::HidePalette)
                    }
                }
                Backspace => {
                    let mut s = state.palette_input.clone();
                    // Defensive: pop only if string is not empty
                    if !s.is_empty() {
                        s.pop();
                    }
                    Some(Action::SetPaletteInput(s))
                }
                Up | Char('k') => Some(Action::PaletteUp),
                Down | Char('j') => Some(Action::PaletteDown),
                Char(c) => {
                    // Build the new input string
                    let mut new_input = state.palette_input.clone();
                    new_input.push(c);
                    
                    // In palette mode, ALL character input is treated as search text
                    // This allows users to type command names (e.g., "rebase todo"), 
                    // keywords, or shortcuts to search and filter commands
                    // Shortcuts only execute when the palette is CLOSED (handled by ShortcutHandler)
                    // In the palette, users must search and press Enter to execute
                    
                    // Add character to input for searching/filtering
                    // The fuzzy matcher will handle finding commands by:
                    // - Command name (e.g., "rebase todo" matches "Rebase todo")
                    // - Keywords (e.g., "rebase" matches commands with "rebase" keyword)
                    // - Shortcut keys (e.g., "rt" matches command with key "rt")
                    Some(Action::SetPaletteInput(new_input))
                }
                _ => None,
            });
        }
        
        Ok(None)
    }
    
    pub fn filter_commands(
        &mut self,
        query: &str,
        state: &AppState,
    ) -> Vec<ScoredCommand> {
        // Extract category filter if present (e.g., "@git push" -> ("push", Some(GitOperations)))
        let (search_query, category_filter) = Self::parse_category_filter(query);
        
        let mut enabled = self.registry.enabled_commands(state);
        
        // Filter by category if specified
        if let Some(category) = category_filter {
            enabled.retain(|cmd| cmd.category == category);
        }
        
        // Apply fuzzy matching on the search query (without @category prefix)
        self.matcher.filter_commands(&search_query, &enabled, state.workflow_context.as_ref())
    }
    
    /// Get custom commands that match the query
    pub fn filter_custom_commands(&self, query: &str, state: &AppState) -> 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()
    }
    
    fn group_by_category(
        commands: &[ScoredCommand],
        category_filter: Option<CommandCategory>,
    ) -> HashMap<CommandCategory, Vec<&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
    }
    
    fn map_grouped_index_to_original(
        grouped: &HashMap<CommandCategory, Vec<&ScoredCommand>>,
        visual_index: usize,
        original_commands: &[ScoredCommand],
    ) -> usize {
        // Flatten grouped commands in the same order as renderer
        let mut flat_commands: Vec<usize> = Vec::new();
        
        // Sort categories for consistent ordering (same as renderer)
        let mut categories: Vec<_> = grouped.keys().collect();
        categories.sort_by_key(|c| format!("{:?}", c));
        
        for category in categories {
            let commands_in_category = &grouped[category];
            
            for cmd in commands_in_category {
                // Find the original index of this command
                let original_idx = original_commands
                    .iter()
                    .position(|c| std::ptr::eq(c.command, cmd.command))
                    .unwrap_or(flat_commands.len());
                flat_commands.push(original_idx);
            }
        }
        
        // Return the original index for the visual position
        flat_commands
            .get(visual_index.min(flat_commands.len().saturating_sub(1)))
            .copied()
            .unwrap_or(visual_index)
    }
    
    fn parse_category_filter(input: &str) -> (String, Option<CommandCategory>) {
        use super::command::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)
    }
}

impl Default for PaletteHandler {
    fn default() -> Self {
        Self::new()
    }
}