From 5401f3707d76591127b55c4ce3d8563edc97de0e Mon Sep 17 00:00:00 2001 From: kjuulh Date: Sun, 22 Sep 2024 11:48:29 +0200 Subject: [PATCH] feat: add interactive search --- Cargo.lock | 242 +++++++++++++++++++++++++++-- crates/gitnow/Cargo.toml | 1 + crates/gitnow/src/commands/root.rs | 35 +++-- crates/gitnow/src/interactive.rs | 160 +++++++++++++++++++ crates/gitnow/src/main.rs | 1 + 5 files changed, 412 insertions(+), 27 deletions(-) create mode 100644 crates/gitnow/src/interactive.rs diff --git a/Cargo.lock b/Cargo.lock index fd90a4a..e83cc63 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,24 @@ version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" +[[package]] +name = "ahash" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e89da841a80418a9b391ebaea17f5c112ffaaa96f621d2c285b5174da76b9011" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "allocator-api2" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c6cb57a04249c6480766f7f7cef5467412af1490f8d1e243141daddada3264f" + [[package]] name = "android-tzdata" version = "0.1.1" @@ -162,10 +180,25 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "428d9aa8fbc0670b7b8d6030a7fadd0f86151cae55e4dbbece15f3780a3dfaf3" [[package]] -name = "cc" -version = "1.1.18" +name = "cassowary" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b62ac837cdb5cb22e10a256099b4fc502b1dfe560cb282963a974d7abd80e476" +checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" + +[[package]] +name = "castaway" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0abae9be0aaf9ea96a3b1b8b1b55c602ca751eba1b1500220cea4ecbafe7c0d5" +dependencies = [ + "rustversion", +] + +[[package]] +name = "cc" +version = "1.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07b1695e2c7e8fc85310cde85aeaab7e3097f593c91d209d3f9df76c928100f0" dependencies = [ "shlex", ] @@ -235,6 +268,20 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3fd119d74b830634cea2a0f58bbd0d54540518a14397557951e79340abc28c0" +[[package]] +name = "compact_str" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6050c3a16ddab2e412160b31f2c871015704239bca62f72f6e5f0be631d3f644" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -251,6 +298,31 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags", + "crossterm_winapi", + "mio", + "parking_lot", + "rustix", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + [[package]] name = "deranged" version = "0.3.11" @@ -497,6 +569,7 @@ dependencies = [ "pretty_assertions", "prost", "prost-types", + "ratatui", "serde", "tokio", "toml", @@ -530,6 +603,10 @@ name = "hashbrown" version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", + "allocator-api2", +] [[package]] name = "heck" @@ -690,9 +767,9 @@ dependencies = [ [[package]] name = "iana-time-zone" -version = "0.1.60" +version = "0.1.61" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7ffbb5a1b541ea2561f8c41c087286cc091e21e556a4f09a8f6cbf17b69b141" +checksum = "235e081f3925a06703c2d0117ea8b91f042756fd6e7a6e5d901e8ca1a996b220" dependencies = [ "android_system_properties", "core-foundation-sys", @@ -731,6 +808,16 @@ dependencies = [ "hashbrown", ] +[[package]] +name = "instability" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b23a0c8dfe501baac4adf6ebbfa6eddf8f0c07f56b058cc1288017e32397846c" +dependencies = [ + "quote", + "syn", +] + [[package]] name = "ipnet" version = "2.10.0" @@ -739,9 +826,9 @@ checksum = "187674a687eed5fe42285b40c6291f9a01517d415fad1c3cbc6a9f778af7fcd4" [[package]] name = "iri-string" -version = "0.7.4" +version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e0f755bd3806e06ad4f366f92639415d99a339a2c7ecf8c26ccea2097c11cb6" +checksum = "9c25163201be6ded9e686703e85532f8f852ea1f92ba625cb3c51f7fe6d07a4a" dependencies = [ "memchr", "serde", @@ -836,6 +923,15 @@ version = "0.4.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24" +[[package]] +name = "lru" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37ee39891760e7d94734f6f63fedc29a2e4a152f836120753a72503f09fcf904" +dependencies = [ + "hashbrown", +] + [[package]] name = "memchr" version = "2.7.4" @@ -875,6 +971,7 @@ checksum = "80e04d1dcff3aae0704555fe5fee3bcfaf3d1fdf8a7e521d5b9d2b42acb52cec" dependencies = [ "hermit-abi", "libc", + "log", "wasi", "windows-sys 0.52.0", ] @@ -1083,6 +1180,12 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + [[package]] name = "pem" version = "3.0.4" @@ -1203,6 +1306,27 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "ratatui" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdef7f9be5c0122f890d58bdf4d964349ba6a6161f705907526d891efabba57d" +dependencies = [ + "bitflags", + "cassowary", + "compact_str", + "crossterm", + "instability", + "itertools", + "lru", + "paste", + "strum", + "strum_macros", + "unicode-segmentation", + "unicode-truncate", + "unicode-width", +] + [[package]] name = "redox_syscall" version = "0.5.4" @@ -1368,6 +1492,12 @@ dependencies = [ "untrusted", ] +[[package]] +name = "rustversion" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "955d28af4278de8121b7ebeb796b6a45735dc01436d898801014aced2773a3d6" + [[package]] name = "ryu" version = "1.0.18" @@ -1413,9 +1543,9 @@ dependencies = [ [[package]] name = "security-framework-sys" -version = "2.11.1" +version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75da29fe9b9b08fe9d6b22b5b4bcbc75d8db3aa31e639aa56bb62e9d46bfceaf" +checksum = "ea4a292869320c0272d7bc55a5a6aafaff59b4f63404a003887b679a2e05b4b6" dependencies = [ "core-foundation-sys", "libc", @@ -1510,6 +1640,27 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signal-hook" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8621587d4798caf8eb44879d42e56b9a93ea5dcd315a6487c357130095b62801" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34db1a06d485c9142248b7a054f034b349b212551f3dfd19c94d45a754a217cd" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + [[package]] name = "signal-hook-registry" version = "1.4.2" @@ -1583,12 +1734,40 @@ version = "0.9.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "strsim" version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn", +] + [[package]] name = "subtle" version = "2.6.1" @@ -1822,9 +2001,9 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.22.20" +version = "0.22.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "583c44c02ad26b0c3f3066fe629275e50627026c51ac2e595cca4c230ce1ce1d" +checksum = "3b072cee73c449a636ffd6f32bd8de3a9f7119139aff882f44943ce2986dc5cf" dependencies = [ "indexmap", "serde", @@ -1969,9 +2148,9 @@ checksum = "e91b56cd4cadaeb79bbf1a5645f6b4f8dc5bde8834ad5894a8db35fda9efa1fe" [[package]] name = "unicode-normalization" -version = "0.1.23" +version = "0.1.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a56d1686db2308d901306f92a263857ef59ea39678a5458e7cb17f01415101f5" +checksum = "5033c97c4262335cded6d6fc3e5c18ab755e1a3dc96376350f3d8e9f009ad956" dependencies = [ "tinyvec", ] @@ -1982,6 +2161,23 @@ version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +[[package]] +name = "unicode-truncate" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" +dependencies = [ + "itertools", + "unicode-segmentation", + "unicode-width", +] + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + [[package]] name = "untrusted" version = "0.9.0" @@ -2350,6 +2546,26 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" +[[package]] +name = "zerocopy" +version = "0.7.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.7.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "zeroize" version = "1.8.1" diff --git a/crates/gitnow/Cargo.toml b/crates/gitnow/Cargo.toml index dcf9df6..e4fdcda 100644 --- a/crates/gitnow/Cargo.toml +++ b/crates/gitnow/Cargo.toml @@ -25,6 +25,7 @@ prost = "0.13.2" prost-types = "0.13.2" bytes = "1.7.1" nucleo-matcher = "0.3.1" +ratatui = "0.28.1" [dev-dependencies] pretty_assertions = "1.4.0" diff --git a/crates/gitnow/src/commands/root.rs b/crates/gitnow/src/commands/root.rs index c7968bc..b8f3ec6 100644 --- a/crates/gitnow/src/commands/root.rs +++ b/crates/gitnow/src/commands/root.rs @@ -5,6 +5,7 @@ use crate::{ cache::CacheApp, fuzzy_matcher::{FuzzyMatcher, FuzzyMatcherApp}, git_provider::Repository, + interactive::InteractiveApp, projects_list::ProjectsListApp, }; @@ -32,23 +33,29 @@ impl RootCommand { repositories } }; - let needle = match search { - Some(needle) => needle.into(), - None => todo!(), - }; + match search { + Some(needle) => { + let matched_repos = self + .app + .fuzzy_matcher() + .match_repositories(&needle.into(), &repositories); - let matched_repos = self - .app - .fuzzy_matcher() - .match_repositories(&needle, &repositories); - let res = matched_repos.iter().take(10).rev().collect::>(); + let repo = matched_repos + .first() + .ok_or(anyhow::anyhow!("failed to find repository"))?; + tracing::info!("selected repo: {}", repo.to_rel_path().display()); + } + None => { + let repo = self + .app + .interactive() + .interactive_search(&repositories)? + .ok_or(anyhow::anyhow!("failed to find a repository"))?; - for repo in res { - tracing::debug!("repo: {:?}", repo); + tracing::info!("selected repo: {}", repo.to_rel_path().display()); + } } - tracing::info!("amount of repos fetched {}", repositories.len()); - Ok(()) } } @@ -69,7 +76,7 @@ impl StringExt for Vec<&String> { } } -trait RepositoryMatcher { +pub trait RepositoryMatcher { fn match_repositories(&self, pattern: &str, repositories: &[Repository]) -> Vec; } diff --git a/crates/gitnow/src/interactive.rs b/crates/gitnow/src/interactive.rs new file mode 100644 index 0000000..a105b9b --- /dev/null +++ b/crates/gitnow/src/interactive.rs @@ -0,0 +1,160 @@ +use app::App; + +use crate::git_provider::Repository; + +pub struct Interactive { + app: &'static crate::app::App, +} + +impl Interactive { + pub fn new(app: &'static crate::app::App) -> Self { + Self { app } + } + + pub fn interactive_search( + &mut self, + repositories: &[Repository], + ) -> anyhow::Result> { + let terminal = ratatui::init(); + let app_result = App::new(self.app, repositories).run(terminal); + ratatui::restore(); + + app_result + } +} + +pub trait InteractiveApp { + fn interactive(&self) -> Interactive; +} + +impl InteractiveApp for &'static crate::app::App { + fn interactive(&self) -> Interactive { + Interactive::new(self) + } +} + +mod app { + use ratatui::{ + crossterm::event::{self, Event, KeyCode}, + layout::{Constraint, Layout}, + style::{Style, Stylize}, + text::{Line, Span, Text}, + widgets::{ListItem, ListState, Paragraph, StatefulWidget}, + DefaultTerminal, Frame, + }; + + use crate::{ + commands::root::RepositoryMatcher, fuzzy_matcher::FuzzyMatcherApp, git_provider::Repository, + }; + + pub struct App<'a> { + app: &'static crate::app::App, + repositories: &'a [Repository], + current_search: String, + matched_repos: Vec, + list: ListState, + } + + impl<'a> App<'a> { + pub fn new(app: &'static crate::app::App, repositories: &'a [Repository]) -> Self { + Self { + app, + repositories, + current_search: String::default(), + matched_repos: Vec::default(), + list: ListState::default(), + } + } + + fn update_matched_repos(&mut self) { + let mut res = self + .app + .fuzzy_matcher() + .match_repositories(&self.current_search, self.repositories); + + //res.reverse(); + + self.matched_repos = res; + + if self.list.selected().is_none() { + self.list.select_first(); + } + } + + pub fn run(mut self, mut terminal: DefaultTerminal) -> anyhow::Result> { + self.update_matched_repos(); + + loop { + terminal.draw(|frame| self.draw(frame))?; + + if let Event::Key(key) = event::read()? { + match key.code { + KeyCode::Char(letter) => { + self.current_search.push(letter); + self.update_matched_repos(); + } + KeyCode::Backspace => { + if !self.current_search.is_empty() { + let _ = self.current_search.remove(self.current_search.len() - 1); + self.update_matched_repos(); + } + } + KeyCode::Esc => return Ok(None), + KeyCode::Enter => { + if let Some(selected) = self.list.selected() { + if let Some(repo) = self.matched_repos.iter().nth(selected).cloned() + { + return Ok(Some(repo)); + } + } + + return Ok(None); + } + KeyCode::Up => self.list.select_next(), + KeyCode::Down => self.list.select_previous(), + _ => {} + } + } + } + } + + fn draw(&mut self, frame: &mut Frame) { + let vertical = Layout::vertical([Constraint::Percentage(100), Constraint::Min(1)]); + let [repository_area, input_area] = vertical.areas(frame.area()); + + let repos = &self.matched_repos; + + let repo_items = repos + .iter() + .map(|r| r.to_rel_path().display().to_string()) + .collect::>(); + + let repo_list_items = repo_items + .into_iter() + .map(ListItem::from) + .collect::>(); + + let repo_list = ratatui::widgets::List::new(repo_list_items) + .direction(ratatui::widgets::ListDirection::BottomToTop) + .scroll_padding(3) + .highlight_symbol("> ") + .highlight_spacing(ratatui::widgets::HighlightSpacing::Always) + .highlight_style(Style::default().bold().white()); + + StatefulWidget::render( + repo_list, + repository_area, + frame.buffer_mut(), + &mut self.list, + ); + + let input = Paragraph::new(Line::from(vec![ + Span::from("> ").blue(), + Span::from(self.current_search.as_str()), + Span::from(" ").on_white(), + ])); + + frame.render_widget(input, input_area); + } + } +} diff --git a/crates/gitnow/src/main.rs b/crates/gitnow/src/main.rs index 4209405..da9849c 100644 --- a/crates/gitnow/src/main.rs +++ b/crates/gitnow/src/main.rs @@ -14,6 +14,7 @@ mod commands; mod config; mod fuzzy_matcher; mod git_provider; +mod interactive; mod projects_list; #[derive(Parser)]