From 77ac254e4eb80e97f7f05da05b358b9bd35f4544 Mon Sep 17 00:00:00 2001 From: Omen Wild Date: Tue, 17 Jun 2025 22:11:35 -0700 Subject: [PATCH 1/4] Changes to be able to sort by Levenshtein distance. "Vibe" coded with Cursor. --- src/main.rs | 5 +++++ src/searchable.rs | 40 +++++++++++++++++++++++++++++++++------- src/ssh.rs | 7 +++++++ src/ui.rs | 2 ++ 4 files changed, 47 insertions(+), 7 deletions(-) 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..a3efa97 100644 --- a/src/searchable.rs +++ b/src/searchable.rs @@ -1,25 +1,33 @@ +use fuzzy_matcher::FuzzyMatcher; +use fuzzy_matcher::skim::SkimMatcherV2; + type SearchableFn = dyn FnMut(&&T, &str) -> bool; +pub trait SearchableItem { + fn search_text(&self) -> &str; +} + pub struct Searchable where - T: Clone, + T: Clone + SearchableItem, { + sort_by_levenshtein: bool, vec: Vec, - 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, filter: Box::new(predicate), @@ -35,12 +43,30 @@ where return; } - self.filtered = self + if self.sort_by_levenshtein { + let matcher = SkimMatcherV2::default(); + let mut items: Vec<_> = self + .vec + .iter() + .filter(|host| (self.filter)(host, value)) + .map(|item| { + let score = 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) + items.sort_by(|a, b| b.1.cmp(&a.1)); + + self.filtered = items.into_iter().map(|(item, _)| item).collect(); + } else { + self.filtered = self .vec .iter() .filter(|host| (self.filter)(host, value)) .cloned() .collect(); + } } #[allow(clippy::must_use_candidate)] @@ -64,7 +90,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 +102,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..9521130 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,12 @@ pub struct Host { pub proxy_command: Option, } +impl SearchableItem for Host { + fn search_text(&self) -> &str { + &self.name + } +} + 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 { From 6904ddcfeecb1ef2076a9b9825905bacb11b3f74 Mon Sep 17 00:00:00 2001 From: Omen Wild Date: Tue, 17 Jun 2025 22:14:22 -0700 Subject: [PATCH 2/4] Simplify to do the matching the same, but only fancy sort if requested. --- src/searchable.rs | 31 ++++++++++++------------------- 1 file changed, 12 insertions(+), 19 deletions(-) diff --git a/src/searchable.rs b/src/searchable.rs index a3efa97..ff3141b 100644 --- a/src/searchable.rs +++ b/src/searchable.rs @@ -43,30 +43,23 @@ where return; } - if self.sort_by_levenshtein { - let matcher = SkimMatcherV2::default(); - let mut items: Vec<_> = self - .vec - .iter() - .filter(|host| (self.filter)(host, value)) - .map(|item| { - let score = 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) - items.sort_by(|a, b| b.1.cmp(&a.1)); - - self.filtered = items.into_iter().map(|(item, _)| item).collect(); - } else { - self.filtered = self + let matcher = SkimMatcherV2::default(); + let mut items: Vec<_> = self .vec .iter() .filter(|host| (self.filter)(host, value)) - .cloned() + .map(|item| { + let score = 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)); } + + self.filtered = items.into_iter().map(|(item, _)| item).collect(); } #[allow(clippy::must_use_candidate)] From 3af83438bf72ba65080a485f3b3d25b98fa96b55 Mon Sep 17 00:00:00 2001 From: Omen Wild Date: Tue, 17 Jun 2025 22:23:45 -0700 Subject: [PATCH 3/4] Make the matcher part of Searchable. --- src/searchable.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/searchable.rs b/src/searchable.rs index ff3141b..0449dff 100644 --- a/src/searchable.rs +++ b/src/searchable.rs @@ -13,6 +13,7 @@ where { sort_by_levenshtein: bool, vec: Vec, + matcher: SkimMatcherV2, filter: Box>, filtered: Vec, } @@ -29,7 +30,7 @@ where let mut searchable = Self { sort_by_levenshtein, vec, - + matcher: SkimMatcherV2::default(), filter: Box::new(predicate), filtered: Vec::new(), }; @@ -43,13 +44,12 @@ where return; } - let matcher = SkimMatcherV2::default(); let mut items: Vec<_> = self .vec .iter() .filter(|host| (self.filter)(host, value)) .map(|item| { - let score = matcher.fuzzy_match(item.search_text(), value).unwrap_or(0); + let score = self.matcher.fuzzy_match(item.search_text(), value).unwrap_or(0); (item.clone(), score) }) .collect(); From b656843921d38c6919fa12cbe587dd16fbd50dec Mon Sep 17 00:00:00 2001 From: Omen Wild Date: Tue, 12 Aug 2025 11:30:05 -0700 Subject: [PATCH 4/4] If the search ends up with zero matches, just return the entered text. This solves #139 for me. --- src/searchable.rs | 10 +++++++++- src/ssh.rs | 11 +++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/src/searchable.rs b/src/searchable.rs index 0449dff..fbf0920 100644 --- a/src/searchable.rs +++ b/src/searchable.rs @@ -5,6 +5,7 @@ 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 @@ -59,7 +60,14 @@ where items.sort_by(|a, b| b.1.cmp(&a.1)); } - self.filtered = items.into_iter().map(|(item, _)| item).collect(); + // 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)] diff --git a/src/ssh.rs b/src/ssh.rs index 9521130..7993b58 100644 --- a/src/ssh.rs +++ b/src/ssh.rs @@ -22,6 +22,17 @@ 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 {