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);
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())));
}
}
let query = state.palette_input.trim();
let is_grouped = query.is_empty() && !state.palette_input.starts_with("@");
let command_idx = if is_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 {
selected
};
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();
if !s.is_empty() {
s.pop();
}
Some(Action::SetPaletteInput(s))
}
Up | Char('k') => Some(Action::PaletteUp),
Down | Char('j') => Some(Action::PaletteDown),
Char(c) => {
let mut new_input = state.palette_input.clone();
new_input.push(c);
Some(Action::SetPaletteInput(new_input))
}
_ => None,
});
}
Ok(None)
}
pub fn filter_commands(
&mut self,
query: &str,
state: &AppState,
) -> Vec<ScoredCommand> {
let (search_query, category_filter) = Self::parse_category_filter(query);
let mut enabled = self.registry.enabled_commands(state);
if let Some(category) = category_filter {
enabled.retain(|cmd| cmd.category == category);
}
self.matcher.filter_commands(&search_query, &enabled, state.workflow_context.as_ref())
}
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 let Some(filter) = category_filter {
if cmd.command.category != filter {
continue;
}
}
grouped
.entry(cmd.command.category)
.or_insert_with(Vec::new)
.push(cmd);
}
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 {
let mut flat_commands: Vec<usize> = Vec::new();
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 {
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);
}
}
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("@") {
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()
}
}