diff --git a/Cargo.lock b/Cargo.lock index ab3a398b..7181cb60 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -695,6 +695,15 @@ version = "0.3.30" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dfc6580bb841c5a68e9ef15c77ccc837b40a7504914d52e47b8b0e9bbda25a1d" +[[package]] +name = "fuzzy-matcher" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54614a3312934d066701a80f20f15fa3b56d67ac7722b39eea5b4c9dd1d66c94" +dependencies = [ + "thread_local", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -1087,6 +1096,12 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2dffe52ecf27772e601905b7522cb4ef790d2cc203488bbd0e2fe85fcb74566d" +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "miniz_oxide" version = "0.7.1" @@ -1127,6 +1142,16 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + [[package]] name = "nonempty" version = "0.5.0" @@ -1615,10 +1640,12 @@ name = "radicle-tui" version = "0.3.0" dependencies = [ "anyhow", + "fuzzy-matcher", "inquire", "lexopt", "libc", "log", + "nom", "radicle", "radicle-surf", "radicle-term", @@ -2282,6 +2309,16 @@ dependencies = [ "winapi", ] +[[package]] +name = "thread_local" +version = "1.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b9ef9bad013ada3808854ceac7b46812a6465ba368859a37e2100283d2d719c" +dependencies = [ + "cfg-if", + "once_cell", +] + [[package]] name = "time" version = "0.1.45" diff --git a/Cargo.toml b/Cargo.toml index bb9f575a..a12ab6cf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,8 +19,10 @@ flux = ["dep:tokio", "dep:tokio-stream"] anyhow = { version = "1" } inquire = { version = "0.6.2", default-features = false, features = ["termion", "editor"] } lexopt = { version = "0.3.0" } +fuzzy-matcher = "*" libc = { version = "^0.2" } log = { version = "0.4.19" } +nom = { version = "^7.1.0" } radicle = { git = "https://seed.radicle.xyz/z3gqcJUoA1n9HaHKufZs5FCSGazv5" } radicle-term = { git = "https://seed.radicle.xyz/z3gqcJUoA1n9HaHKufZs5FCSGazv5", package = "radicle-term" } radicle-surf = { version = "0.18.0" } diff --git a/src/flux/ui/cob.rs b/src/flux/ui/cob.rs index 97282385..5013f651 100644 --- a/src/flux/ui/cob.rs +++ b/src/flux/ui/cob.rs @@ -1,18 +1,26 @@ +use std::str::FromStr; + +use nom::bytes::complete::{tag, take}; +use nom::multi::separated_list0; +use nom::sequence::{delimited, preceded}; +use nom::{IResult, Parser}; + +use radicle::cob::{Label, ObjectId, Timestamp, TypedId}; use radicle::crypto::PublicKey; use radicle::git::Oid; use radicle::identity::{Did, Identity}; -use radicle::node::{Alias, NodeId}; -use radicle::{issue, patch, Profile}; -use ratatui::style::{Color, Style, Stylize}; -use ratatui::widgets::Cell; - -use radicle::cob::{Label, ObjectId, Timestamp, TypedId}; +use radicle::issue; use radicle::issue::{Issue, IssueId, Issues}; use radicle::node::notifications::{Notification, NotificationId, NotificationKind}; -use radicle::node::AliasStore; +use radicle::node::{Alias, AliasStore, NodeId}; +use radicle::patch; use radicle::patch::{Patch, PatchId, Patches}; use radicle::storage::git::Repository; use radicle::storage::{ReadRepository, ReadStorage, RefUpdate, WriteRepository}; +use radicle::Profile; + +use ratatui::style::{Color, Style, Stylize}; +use ratatui::widgets::Cell; use super::super::git; use super::theme::style; @@ -490,6 +498,107 @@ impl ToRow<9> for PatchItem { } } +#[derive(Clone, Default, Debug, Eq, PartialEq)] +pub struct PatchItemFilter { + status: Option, + authored: bool, + authors: Vec, + search: Option, +} + +impl PatchItemFilter { + pub fn status(&self) -> Option { + self.status + } + + pub fn matches(&self, patch: &PatchItem) -> bool { + use fuzzy_matcher::skim::SkimMatcherV2; + use fuzzy_matcher::FuzzyMatcher; + + let matcher = SkimMatcherV2::default(); + + let matches_state = match self.status { + Some(patch::Status::Draft) => matches!(patch.state, patch::State::Draft), + Some(patch::Status::Open) => matches!(patch.state, patch::State::Open { .. }), + Some(patch::Status::Merged) => matches!(patch.state, patch::State::Merged { .. }), + Some(patch::Status::Archived) => matches!(patch.state, patch::State::Archived), + None => true, + }; + + let matches_authored = if self.authored { + patch.author.you + } else { + true + }; + + let matches_authors = (!self.authors.is_empty()) + .then(|| { + self.authors + .iter() + .any(|other| patch.author.nid.unwrap() == **other) + }) + .unwrap_or(true); + + let matches_search = match &self.search { + Some(search) => match matcher.fuzzy_match(&patch.title, search) { + Some(score) => score == 0 || score > 60, + _ => false, + }, + None => true, + }; + + matches_state && matches_authored && matches_authors && matches_search + } +} + +impl FromStr for PatchItemFilter { + type Err = anyhow::Error; + + fn from_str(value: &str) -> Result { + let mut status = None; + let mut search = String::new(); + let mut authored = false; + let mut authors = vec![]; + + let mut authors_parser = |input| -> IResult<&str, Vec<&str>> { + preceded( + tag("authors:"), + delimited( + tag("["), + separated_list0(tag(","), take(56_usize)), + tag("]"), + ), + )(input) + }; + + let parts = value.split(' '); + for part in parts { + match part { + "is:open" => status = Some(patch::Status::Open), + "is:merged" => status = Some(patch::Status::Merged), + "is:archived" => status = Some(patch::Status::Archived), + "is:draft" => status = Some(patch::Status::Draft), + "is:authored" => authored = true, + other => match authors_parser.parse(other) { + Ok((_, dids)) => { + for did in dids { + authors.push(Did::from_str(did)?); + } + } + _ => search.push_str(other), + }, + } + } + + Ok(Self { + status, + authored, + authors, + search: Some(search), + }) + } +} + pub fn format_issue_state(state: &issue::State) -> (String, Color) { match state { issue::State::Open => (" ● ".into(), Color::Green), @@ -551,3 +660,30 @@ pub fn format_assignees(assignees: &[(Option, Option, bool)]) } output } + +#[cfg(test)] +mod tests { + use anyhow::Result; + + use super::*; + + #[test] + fn patch_item_filter_from_str_should_succeed() -> Result<()> { + let search = r#"is:open is:authored authors:[did:key:z6MkkpTPzcq1ybmjQyQpyre15JUeMvZY6toxoZVpLZ8YarsB,did:key:z6Mku8hpprWTmCv3BqkssCYDfr2feUdyLSUnycVajFo9XVAx] cli"#; + let actual = PatchItemFilter::from_str(search)?; + + let expected = PatchItemFilter { + status: Some(patch::Status::Open), + authored: true, + authors: vec![ + Did::from_str("did:key:z6MkkpTPzcq1ybmjQyQpyre15JUeMvZY6toxoZVpLZ8YarsB")?, + Did::from_str("did:key:z6Mku8hpprWTmCv3BqkssCYDfr2feUdyLSUnycVajFo9XVAx")?, + ], + search: Some("cli".to_string()), + }; + + assert_eq!(expected, actual); + + Ok(()) + } +}