use crate::input::commands::{get_all_commands, Command, Suggestion};
use crate::input::fuzzy::fuzzy_match;
use crate::input::keybindings::Action;
use crate::input::keybindings::KeyContext;
use std::sync::{Arc, RwLock};
pub struct CommandRegistry {
builtin_commands: Vec<Command>,
plugin_commands: Arc<RwLock<Vec<Command>>>,
command_history: Vec<String>,
}
impl CommandRegistry {
const MAX_HISTORY_SIZE: usize = 50;
pub fn new() -> Self {
Self {
builtin_commands: get_all_commands(),
plugin_commands: Arc::new(RwLock::new(Vec::new())),
command_history: Vec::new(),
}
}
pub fn refresh_builtin_commands(&mut self) {
self.builtin_commands = get_all_commands();
}
pub fn record_usage(&mut self, command_name: &str) {
self.command_history.retain(|name| name != command_name);
self.command_history.insert(0, command_name.to_string());
if self.command_history.len() > Self::MAX_HISTORY_SIZE {
self.command_history.truncate(Self::MAX_HISTORY_SIZE);
}
}
fn history_position(&self, command_name: &str) -> Option<usize> {
self.command_history
.iter()
.position(|name| name == command_name)
}
pub fn register(&self, command: Command) {
let mut commands = self.plugin_commands.write().unwrap();
commands.retain(|c| c.name != command.name);
commands.push(command);
}
pub fn unregister(&self, name: &str) {
let mut commands = self.plugin_commands.write().unwrap();
commands.retain(|c| c.name != name);
}
pub fn unregister_by_prefix(&self, prefix: &str) {
let mut commands = self.plugin_commands.write().unwrap();
commands.retain(|c| !c.name.starts_with(prefix));
}
pub fn get_all(&self) -> Vec<Command> {
let mut all_commands = self.builtin_commands.clone();
let plugin_commands = self.plugin_commands.read().unwrap();
all_commands.extend(plugin_commands.iter().cloned());
all_commands
}
pub fn filter(
&self,
query: &str,
current_context: KeyContext,
keybinding_resolver: &crate::input::keybindings::KeybindingResolver,
selection_active: bool,
active_custom_contexts: &std::collections::HashSet<String>,
active_buffer_mode: Option<&str>,
) -> Vec<Suggestion> {
let commands = self.get_all();
let is_visible = |cmd: &Command| -> bool {
cmd.custom_contexts.is_empty()
|| cmd.custom_contexts.iter().all(|ctx| {
active_custom_contexts.contains(ctx)
|| active_buffer_mode.map_or(false, |mode| mode == ctx)
})
};
let is_available = |cmd: &Command| -> bool {
if cmd.contexts.contains(&KeyContext::Global) {
return true;
}
cmd.contexts.is_empty() || cmd.contexts.contains(¤t_context)
};
let make_suggestion =
|cmd: &Command, score: i32, localized_name: String, localized_desc: String| {
let mut available = is_available(cmd);
if cmd.action == Action::FindInSelection && !selection_active {
available = false;
}
let keybinding =
keybinding_resolver.get_keybinding_for_action(&cmd.action, current_context);
let history_pos = self.history_position(&cmd.name);
let suggestion = Suggestion::with_source(
localized_name,
Some(localized_desc),
!available,
keybinding,
Some(cmd.source.clone()),
);
(suggestion, history_pos, score)
};
let mut suggestions: Vec<(Suggestion, Option<usize>, i32)> = commands
.iter()
.filter(|cmd| is_visible(cmd))
.filter_map(|cmd| {
let localized_name = cmd.get_localized_name();
let name_result = fuzzy_match(query, &localized_name);
if name_result.matched {
let localized_desc = cmd.get_localized_description();
Some(make_suggestion(
cmd,
name_result.score,
localized_name,
localized_desc,
))
} else {
None
}
})
.collect();
if suggestions.is_empty() && !query.is_empty() {
suggestions = commands
.iter()
.filter(|cmd| is_visible(cmd))
.filter_map(|cmd| {
let localized_desc = cmd.get_localized_description();
let desc_result = fuzzy_match(query, &localized_desc);
if desc_result.matched {
let localized_name = cmd.get_localized_name();
Some(make_suggestion(
cmd,
desc_result.score.saturating_sub(50),
localized_name,
localized_desc,
))
} else {
None
}
})
.collect();
}
let has_query = !query.is_empty();
suggestions.sort_by(|(a, a_hist, a_score), (b, b_hist, b_score)| {
match a.disabled.cmp(&b.disabled) {
std::cmp::Ordering::Equal => {}
other => return other,
}
if has_query {
match b_score.cmp(a_score) {
std::cmp::Ordering::Equal => {}
other => return other,
}
}
match (a_hist, b_hist) {
(Some(a_pos), Some(b_pos)) => a_pos.cmp(b_pos),
(Some(_), None) => std::cmp::Ordering::Less, (None, Some(_)) => std::cmp::Ordering::Greater,
(None, None) => a.text.cmp(&b.text), }
});
suggestions.into_iter().map(|(s, _, _)| s).collect()
}
pub fn plugin_command_count(&self) -> usize {
self.plugin_commands.read().unwrap().len()
}
pub fn total_command_count(&self) -> usize {
self.builtin_commands.len() + self.plugin_command_count()
}
pub fn find_by_name(&self, name: &str) -> Option<Command> {
{
let plugin_commands = self.plugin_commands.read().unwrap();
if let Some(cmd) = plugin_commands.iter().find(|c| c.name == name) {
return Some(cmd.clone());
}
}
self.builtin_commands
.iter()
.find(|c| c.name == name)
.cloned()
}
}
impl Default for CommandRegistry {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::input::commands::CommandSource;
use crate::input::keybindings::Action;
#[test]
fn test_command_registry_creation() {
let registry = CommandRegistry::new();
assert!(registry.total_command_count() > 0); assert_eq!(registry.plugin_command_count(), 0); }
#[test]
fn test_register_command() {
let registry = CommandRegistry::new();
let custom_command = Command {
name: "Test Command".to_string(),
description: "A test command".to_string(),
action: Action::None,
contexts: vec![],
custom_contexts: vec![],
source: CommandSource::Builtin,
};
registry.register(custom_command.clone());
assert_eq!(registry.plugin_command_count(), 1);
let found = registry.find_by_name("Test Command");
assert!(found.is_some());
assert_eq!(found.unwrap().description, "A test command");
}
#[test]
fn test_unregister_command() {
let registry = CommandRegistry::new();
let custom_command = Command {
name: "Test Command".to_string(),
description: "A test command".to_string(),
action: Action::None,
contexts: vec![],
custom_contexts: vec![],
source: CommandSource::Builtin,
};
registry.register(custom_command);
assert_eq!(registry.plugin_command_count(), 1);
registry.unregister("Test Command");
assert_eq!(registry.plugin_command_count(), 0);
}
#[test]
fn test_register_replaces_existing() {
let registry = CommandRegistry::new();
let command1 = Command {
name: "Test Command".to_string(),
description: "First version".to_string(),
action: Action::None,
contexts: vec![],
custom_contexts: vec![],
source: CommandSource::Builtin,
};
let command2 = Command {
name: "Test Command".to_string(),
description: "Second version".to_string(),
action: Action::None,
contexts: vec![],
custom_contexts: vec![],
source: CommandSource::Builtin,
};
registry.register(command1);
assert_eq!(registry.plugin_command_count(), 1);
registry.register(command2);
assert_eq!(registry.plugin_command_count(), 1);
let found = registry.find_by_name("Test Command").unwrap();
assert_eq!(found.description, "Second version");
}
#[test]
fn test_unregister_by_prefix() {
let registry = CommandRegistry::new();
registry.register(Command {
name: "Plugin A: Command 1".to_string(),
description: "".to_string(),
action: Action::None,
contexts: vec![],
custom_contexts: vec![],
source: CommandSource::Builtin,
});
registry.register(Command {
name: "Plugin A: Command 2".to_string(),
description: "".to_string(),
action: Action::None,
contexts: vec![],
custom_contexts: vec![],
source: CommandSource::Builtin,
});
registry.register(Command {
name: "Plugin B: Command".to_string(),
description: "".to_string(),
action: Action::None,
contexts: vec![],
custom_contexts: vec![],
source: CommandSource::Builtin,
});
assert_eq!(registry.plugin_command_count(), 3);
registry.unregister_by_prefix("Plugin A:");
assert_eq!(registry.plugin_command_count(), 1);
let remaining = registry.find_by_name("Plugin B: Command");
assert!(remaining.is_some());
}
#[test]
fn test_filter_commands() {
use crate::config::Config;
use crate::input::keybindings::KeybindingResolver;
let registry = CommandRegistry::new();
let config = Config::default();
let keybindings = KeybindingResolver::new(&config);
registry.register(Command {
name: "Test Save".to_string(),
description: "Test save command".to_string(),
action: Action::None,
contexts: vec![KeyContext::Normal],
custom_contexts: vec![],
source: CommandSource::Builtin,
});
let empty_contexts = std::collections::HashSet::new();
let results = registry.filter(
"save",
KeyContext::Normal,
&keybindings,
false,
&empty_contexts,
None,
);
assert!(results.len() >= 2);
let names: Vec<String> = results.iter().map(|s| s.text.clone()).collect();
assert!(names.iter().any(|n| n.contains("Save")));
}
#[test]
fn test_context_filtering() {
use crate::config::Config;
use crate::input::keybindings::KeybindingResolver;
let registry = CommandRegistry::new();
let config = Config::default();
let keybindings = KeybindingResolver::new(&config);
registry.register(Command {
name: "Normal Only".to_string(),
description: "Available only in normal context".to_string(),
action: Action::None,
contexts: vec![KeyContext::Normal],
custom_contexts: vec![],
source: CommandSource::Builtin,
});
registry.register(Command {
name: "Popup Only".to_string(),
description: "Available only in popup context".to_string(),
action: Action::None,
contexts: vec![KeyContext::Popup],
custom_contexts: vec![],
source: CommandSource::Builtin,
});
let empty_contexts = std::collections::HashSet::new();
let results = registry.filter(
"",
KeyContext::Normal,
&keybindings,
false,
&empty_contexts,
None,
);
let popup_only = results.iter().find(|s| s.text == "Popup Only");
assert!(popup_only.is_some());
assert!(popup_only.unwrap().disabled);
let results = registry.filter(
"",
KeyContext::Popup,
&keybindings,
false,
&empty_contexts,
None,
);
let normal_only = results.iter().find(|s| s.text == "Normal Only");
assert!(normal_only.is_some());
assert!(normal_only.unwrap().disabled);
}
#[test]
fn test_get_all_merges_commands() {
let registry = CommandRegistry::new();
let initial_count = registry.total_command_count();
registry.register(Command {
name: "Custom 1".to_string(),
description: "".to_string(),
action: Action::None,
contexts: vec![],
custom_contexts: vec![],
source: CommandSource::Builtin,
});
registry.register(Command {
name: "Custom 2".to_string(),
description: "".to_string(),
action: Action::None,
contexts: vec![],
custom_contexts: vec![],
source: CommandSource::Builtin,
});
let all = registry.get_all();
assert_eq!(all.len(), initial_count + 2);
}
#[test]
fn test_plugin_command_overrides_builtin() {
let registry = CommandRegistry::new();
let builtin = registry.find_by_name("Save File");
assert!(builtin.is_some());
let original_desc = builtin.unwrap().description;
registry.register(Command {
name: "Save File".to_string(),
description: "Custom save implementation".to_string(),
action: Action::None,
contexts: vec![],
custom_contexts: vec![],
source: CommandSource::Builtin,
});
let custom = registry.find_by_name("Save File").unwrap();
assert_eq!(custom.description, "Custom save implementation");
assert_ne!(custom.description, original_desc);
}
#[test]
fn test_record_usage() {
let mut registry = CommandRegistry::new();
registry.record_usage("Save File");
assert_eq!(registry.history_position("Save File"), Some(0));
registry.record_usage("Open File");
assert_eq!(registry.history_position("Open File"), Some(0));
assert_eq!(registry.history_position("Save File"), Some(1));
registry.record_usage("Save File");
assert_eq!(registry.history_position("Save File"), Some(0));
assert_eq!(registry.history_position("Open File"), Some(1));
}
#[test]
fn test_history_sorting() {
use crate::config::Config;
use crate::input::keybindings::KeybindingResolver;
let mut registry = CommandRegistry::new();
let config = Config::default();
let keybindings = KeybindingResolver::new(&config);
registry.record_usage("Quit");
registry.record_usage("Save File");
registry.record_usage("Open File");
let empty_contexts = std::collections::HashSet::new();
let results = registry.filter(
"",
KeyContext::Normal,
&keybindings,
false,
&empty_contexts,
None,
);
let open_pos = results.iter().position(|s| s.text == "Open File").unwrap();
let save_pos = results.iter().position(|s| s.text == "Save File").unwrap();
let quit_pos = results.iter().position(|s| s.text == "Quit").unwrap();
assert!(
open_pos < save_pos,
"Open File should come before Save File"
);
assert!(save_pos < quit_pos, "Save File should come before Quit");
}
#[test]
fn test_history_max_size() {
let mut registry = CommandRegistry::new();
for i in 0..60 {
registry.record_usage(&format!("Command {}", i));
}
assert_eq!(
registry.command_history.len(),
CommandRegistry::MAX_HISTORY_SIZE
);
assert_eq!(registry.history_position("Command 59"), Some(0));
assert_eq!(registry.history_position("Command 0"), None);
}
#[test]
fn test_unused_commands_alphabetical() {
use crate::config::Config;
use crate::input::keybindings::KeybindingResolver;
let mut registry = CommandRegistry::new();
let config = Config::default();
let keybindings = KeybindingResolver::new(&config);
registry.register(Command {
name: "Zebra Command".to_string(),
description: "".to_string(),
action: Action::None,
contexts: vec![],
custom_contexts: vec![],
source: CommandSource::Builtin,
});
registry.register(Command {
name: "Alpha Command".to_string(),
description: "".to_string(),
action: Action::None,
contexts: vec![],
custom_contexts: vec![],
source: CommandSource::Builtin,
});
registry.record_usage("Save File");
let empty_contexts = std::collections::HashSet::new();
let results = registry.filter(
"",
KeyContext::Normal,
&keybindings,
false,
&empty_contexts,
None,
);
let save_pos = results.iter().position(|s| s.text == "Save File").unwrap();
let alpha_pos = results
.iter()
.position(|s| s.text == "Alpha Command")
.unwrap();
let zebra_pos = results
.iter()
.position(|s| s.text == "Zebra Command")
.unwrap();
assert!(
save_pos < alpha_pos,
"Save File should come before Alpha Command"
);
assert!(
alpha_pos < zebra_pos,
"Alpha Command should come before Zebra Command"
);
}
#[test]
fn test_required_commands_exist() {
crate::i18n::set_locale("en");
let registry = CommandRegistry::new();
let required_commands = [
("Show Completions", Action::LspCompletion),
("Go to Definition", Action::LspGotoDefinition),
("Show Hover Info", Action::LspHover),
("Find References", Action::LspReferences),
("Show Manual", Action::ShowHelp),
("Show Keyboard Shortcuts", Action::ShowKeyboardShortcuts),
("Scroll Up", Action::ScrollUp),
("Scroll Down", Action::ScrollDown),
("Scroll Tabs Left", Action::ScrollTabsLeft),
("Scroll Tabs Right", Action::ScrollTabsRight),
("Smart Home", Action::SmartHome),
("Delete Word Backward", Action::DeleteWordBackward),
("Delete Word Forward", Action::DeleteWordForward),
("Delete to End of Line", Action::DeleteToLineEnd),
];
for (name, expected_action) in required_commands {
let cmd = registry.find_by_name(name);
assert!(
cmd.is_some(),
"Command '{}' should exist in command palette",
name
);
assert_eq!(
cmd.unwrap().action,
expected_action,
"Command '{}' should have action {:?}",
name,
expected_action
);
}
}
}