diff --git a/src/main.rs b/src/main.rs index 224495c..78aaa14 100644 --- a/src/main.rs +++ b/src/main.rs @@ -34,6 +34,10 @@ struct Args { #[arg(long, default_value_t = true)] sort: bool, + /// Fancy Levenshtein sort by edit distance + #[arg(long, default_value_t = false)] + sort_fancy: bool, + /// Handlebars template of the command to execute #[arg(short, long, default_value = "ssh \"{{{name}}}\"")] template: String, @@ -58,6 +62,7 @@ fn main() -> Result<()> { config_paths: args.config, search_filter: args.search, sort_by_name: args.sort, + sort_by_levenshtein: args.sort_fancy, show_proxy_command: args.show_proxy_command, command_template: args.template, command_template_on_session_start: args.on_session_start_template, diff --git a/src/searchable.rs b/src/searchable.rs index a0d8753..fbf0920 100644 --- a/src/searchable.rs +++ b/src/searchable.rs @@ -1,27 +1,37 @@ +use fuzzy_matcher::FuzzyMatcher; +use fuzzy_matcher::skim::SkimMatcherV2; + type SearchableFn = dyn FnMut(&&T, &str) -> bool; +pub trait SearchableItem { + fn search_text(&self) -> &str; + fn from_search_value(value: &str) -> Self; +} + pub struct Searchable where - T: Clone, + T: Clone + SearchableItem, { + sort_by_levenshtein: bool, vec: Vec, - + matcher: SkimMatcherV2, filter: Box>, filtered: Vec, } impl Searchable where - T: Clone, + T: Clone + SearchableItem, { #[must_use] - pub fn new

(vec: Vec, search_value: &str, predicate: P) -> Self + pub fn new

(sort_by_levenshtein: bool, vec: Vec, search_value: &str, predicate: P) -> Self where P: FnMut(&&T, &str) -> bool + 'static, { let mut searchable = Self { + sort_by_levenshtein, vec, - + matcher: SkimMatcherV2::default(), filter: Box::new(predicate), filtered: Vec::new(), }; @@ -35,12 +45,29 @@ where return; } - self.filtered = self + let mut items: Vec<_> = self .vec .iter() .filter(|host| (self.filter)(host, value)) - .cloned() + .map(|item| { + let score = self.matcher.fuzzy_match(item.search_text(), value).unwrap_or(0); + (item.clone(), score) + }) .collect(); + + // Sort by Levenshtein distance in descending order (higher score = better match) + if self.sort_by_levenshtein { + items.sort_by(|a, b| b.1.cmp(&a.1)); + } + + // If no results found, return the search value itself + if items.is_empty() { + // Create a dummy item with the search value + let dummy_item = T::from_search_value(value); + self.filtered = vec![dummy_item]; + } else { + self.filtered = items.into_iter().map(|(item, _)| item).collect(); + } } #[allow(clippy::must_use_candidate)] @@ -64,7 +91,7 @@ where impl<'a, T> IntoIterator for &'a Searchable where - T: Clone, + T: Clone + SearchableItem, { type Item = &'a T; type IntoIter = std::slice::Iter<'a, T>; @@ -76,7 +103,7 @@ where impl std::ops::Index for Searchable where - T: Clone, + T: Clone + SearchableItem, { type Output = T; diff --git a/src/ssh.rs b/src/ssh.rs index cc79745..7993b58 100644 --- a/src/ssh.rs +++ b/src/ssh.rs @@ -6,6 +6,7 @@ use std::collections::VecDeque; use std::process::Command; use crate::ssh_config::{self, parser_error::ParseError, HostVecExt}; +use crate::searchable::SearchableItem; #[derive(Debug, Serialize, Clone)] pub struct Host { @@ -17,6 +18,23 @@ pub struct Host { pub proxy_command: Option, } +impl SearchableItem for Host { + fn search_text(&self) -> &str { + &self.name + } + + fn from_search_value(value: &str) -> Self { + Host { + name: value.to_string(), + aliases: String::new(), + user: None, + destination: String::new(), + port: None, + proxy_command: None, + } + } +} + impl Host { /// Uses the provided Handlebars template to run a command. /// diff --git a/src/ui.rs b/src/ui.rs index b473366..29a2ec4 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -32,6 +32,7 @@ pub struct AppConfig { pub search_filter: Option, pub sort_by_name: bool, + pub sort_by_levenshtein: bool, pub show_proxy_command: bool, pub command_template: String, @@ -103,6 +104,7 @@ impl App { palette: tailwind::BLUE, hosts: Searchable::new( + config.sort_by_levenshtein, hosts, &search_input, move |host: &&ssh::Host, search_value: &str| -> bool {