From 1ad98ca7809b37de7c4b5fcb81fbbffda290b4f3 Mon Sep 17 00:00:00 2001 From: trivernis Date: Sun, 4 Sep 2022 14:52:17 +0200 Subject: [PATCH] Add fuzzy file review selector Signed-off-by: trivernis --- Cargo.lock | 11 +++++ Cargo.toml | 3 +- src/builder/makepkg.rs | 2 +- src/builder/pager.rs | 8 +--- src/interact/macros.rs | 34 ++++++++++++++-- src/interact/mod.rs | 6 +++ src/interact/multi_select.rs | 18 ++++---- src/interact/prompt.rs | 8 +--- src/interact/select.rs | 51 +++++++++++++++++++++++ src/interact/theme.rs | 52 +++++++++++++++++++++++- src/internal/error.rs | 2 + src/internal/utils.rs | 6 ++- src/logging/handler.rs | 31 ++++++++++++-- src/operations/aur_install/aur_review.rs | 42 +++++++++++++++++-- src/operations/aur_install/mod.rs | 3 ++ 15 files changed, 239 insertions(+), 38 deletions(-) create mode 100644 src/interact/select.rs diff --git a/Cargo.lock b/Cargo.lock index cf9cde9..c22b6c1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,7 @@ dependencies = [ "dialoguer", "directories", "futures", + "fuzzy-matcher", "indicatif", "lazy-regex", "lazy_static", @@ -307,6 +308,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a92e7e37ecef6857fdc0c0c5d42fd5b0938e46590c2183cc92dd310a6d078eb1" dependencies = [ "console", + "fuzzy-matcher", "tempfile", "zeroize", ] @@ -491,6 +493,15 @@ dependencies = [ "slab", ] +[[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 = "getrandom" version = "0.2.7" diff --git a/Cargo.toml b/Cargo.toml index 46ad32c..1bfece5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -44,11 +44,12 @@ color-eyre = { version = "0.6.2", features = ["issue-url", "url"] } indicatif = { version = "0.17.0", features = ["tokio"] } lazy_static = "1.4.0" parking_lot = { version = "0.12.1", features = ["deadlock_detection"] } -dialoguer = "0.10.2" +dialoguer = { version = "0.10.2", features = ["fuzzy-select"] } lazy-regex = "2.3.0" directories = "4.0.1" console = "0.15.1" tracing-error = "0.2.0" +fuzzy-matcher = "0.3.7" [dependencies.tokio] version = "1.21.0" diff --git a/src/builder/makepkg.rs b/src/builder/makepkg.rs index 9e3582c..dd01ca7 100644 --- a/src/builder/makepkg.rs +++ b/src/builder/makepkg.rs @@ -89,7 +89,7 @@ impl MakePkgBuilder { if output.status.success() { Ok(()) } else { - Err(AppError::Other(output.stderr)) + Err(AppError::MakePkg(output.stderr)) } } diff --git a/src/builder/pager.rs b/src/builder/pager.rs index 73892e3..f21f530 100644 --- a/src/builder/pager.rs +++ b/src/builder/pager.rs @@ -2,7 +2,7 @@ use std::path::{Path, PathBuf}; use crate::{ internal::{commands::ShellCommand, error::AppResult}, - logging::get_logger, + with_suspended_output, }; #[derive(Default)] @@ -18,10 +18,6 @@ impl PagerBuilder { } pub async fn open(self) -> AppResult<()> { - get_logger().suspend(); - ShellCommand::pager().arg(self.path).wait_success().await?; - get_logger().unsuspend(); - - Ok(()) + with_suspended_output!({ ShellCommand::pager().arg(self.path).wait_success().await }) } } diff --git a/src/interact/macros.rs b/src/interact/macros.rs index 753338c..fd4f88d 100644 --- a/src/interact/macros.rs +++ b/src/interact/macros.rs @@ -20,6 +20,13 @@ macro_rules! multi_select { } } +#[macro_export] +macro_rules! select_opt { + ($items:expr, $($arg:tt)+) => { + $crate::interact::InteractOpt::interact_opt($crate::interact::AmeFuzzySelect::new(format!($($arg)+)).items($items)) + }; +} + #[macro_export] /// Returns a singular or plural expression depending on the given len /// Usage: @@ -82,16 +89,35 @@ macro_rules! normal_output { #[macro_export] /// Suspends the output so that nothing is being written to stdout/stderr -macro_rules! supend_output { +/// Returns a handle that unsuspend the output when it's dropped +macro_rules! suspend_output { () => { - $crate::loggign::get_logger().suspend(); + $crate::logging::get_logger().suspend() }; } #[macro_export] /// Unsuspends the output and writes everything buffered to stdout/stderr -macro_rules! unsupend_output { +macro_rules! unsuspend_output { + () => { + $crate::logging::get_logger().unsuspend(); + }; +} + +#[macro_export] +/// Suspend all output logging inside the given block +/// Note: This only works as long as the block itself doesn't unsuspend +/// the output +macro_rules! with_suspended_output { + ($expr:block) => {{ + let _handle = $crate::suspend_output!(); + $expr + }}; +} + +#[macro_export] +macro_rules! newline { () => { - $crate::loggign::get_logger().unsuspend(); + $crate::logging::get_logger().print_newline(); }; } diff --git a/src/interact/mod.rs b/src/interact/mod.rs index a48be3d..3ebcf7a 100644 --- a/src/interact/mod.rs +++ b/src/interact/mod.rs @@ -1,13 +1,19 @@ pub mod macros; mod multi_select; mod prompt; +mod select; mod theme; pub use multi_select::AmeMultiSelect; pub use prompt::AmePrompt; +pub use select::AmeFuzzySelect; pub trait Interact { type Result; fn interact(&mut self) -> Self::Result; } + +pub trait InteractOpt: Interact { + fn interact_opt(&mut self) -> Option; +} diff --git a/src/interact/multi_select.rs b/src/interact/multi_select.rs index d656c46..ae04259 100644 --- a/src/interact/multi_select.rs +++ b/src/interact/multi_select.rs @@ -1,6 +1,6 @@ use std::mem; -use crate::logging::get_logger; +use crate::with_suspended_output; use super::{theme::AmeTheme, Interact}; @@ -30,14 +30,12 @@ impl Interact for AmeMultiSelect { type Result = Vec; fn interact(&mut self) -> Self::Result { - get_logger().suspend(); - let selection = dialoguer::MultiSelect::with_theme(AmeTheme::get()) - .with_prompt(mem::take(&mut self.prompt)) - .items(&self.items) - .interact() - .unwrap(); - get_logger().unsuspend(); - - selection + with_suspended_output!({ + dialoguer::MultiSelect::with_theme(AmeTheme::get()) + .with_prompt(mem::take(&mut self.prompt)) + .items(&self.items) + .interact() + .unwrap() + }) } } diff --git a/src/interact/prompt.rs b/src/interact/prompt.rs index 64fb768..202ade1 100644 --- a/src/interact/prompt.rs +++ b/src/interact/prompt.rs @@ -1,6 +1,6 @@ use std::mem; -use crate::logging::get_logger; +use crate::with_suspended_output; use super::{theme::AmeTheme, Interact}; @@ -46,10 +46,6 @@ impl Interact for AmePrompt { dialog .with_prompt(mem::take(&mut self.question)) .wait_for_newline(true); - get_logger().suspend(); - let result = dialog.interact().unwrap(); - get_logger().unsuspend(); - - result + with_suspended_output!({ dialog.interact().unwrap() }) } } diff --git a/src/interact/select.rs b/src/interact/select.rs new file mode 100644 index 0000000..5794b74 --- /dev/null +++ b/src/interact/select.rs @@ -0,0 +1,51 @@ +use std::mem; + +use crate::with_suspended_output; + +use super::{theme::AmeTheme, Interact, InteractOpt}; + +pub struct AmeFuzzySelect { + prompt: String, + items: Vec, +} + +impl AmeFuzzySelect { + /// Creates a new multi select prompt + pub fn new(prompt: S) -> Self { + Self { + prompt: prompt.to_string(), + items: Vec::new(), + } + } + + /// Adds/replaces the items of this multi select + pub fn items, S: ToString>(&mut self, items: I) -> &mut Self { + self.items = items.into_iter().map(|i| i.to_string()).collect(); + + self + } + + fn build(&mut self) -> dialoguer::FuzzySelect { + let mut select = dialoguer::FuzzySelect::with_theme(AmeTheme::get()); + select + .with_prompt(mem::take(&mut self.prompt)) + .items(&self.items) + .default(0); + + select + } +} + +impl Interact for AmeFuzzySelect { + type Result = usize; + + fn interact(&mut self) -> Self::Result { + with_suspended_output!({ self.build().interact().unwrap() }) + } +} + +impl InteractOpt for AmeFuzzySelect { + fn interact_opt(&mut self) -> Option { + with_suspended_output!({ self.build().interact_opt().unwrap() }) + } +} diff --git a/src/interact/theme.rs b/src/interact/theme.rs index 9a4b412..c019921 100644 --- a/src/interact/theme.rs +++ b/src/interact/theme.rs @@ -1,8 +1,8 @@ use crossterm::style::Stylize; use dialoguer::theme::Theme; +use fuzzy_matcher::{skim::SkimMatcherV2, FuzzyMatcher}; use crate::internal::utils::wrap_text; - const ERR_SYMBOL: &str = "X"; const PROMPT_SYMBOL: &str = "?"; @@ -208,4 +208,54 @@ impl Theme for AmeTheme { text ) } + + fn format_fuzzy_select_prompt( + &self, + f: &mut dyn std::fmt::Write, + prompt: &str, + search_term: &str, + cursor_pos: usize, + ) -> std::fmt::Result { + if !prompt.is_empty() { + write!(f, "{} {} ", PROMPT_SYMBOL.magenta(), prompt.bold())?; + } + + if cursor_pos < search_term.len() { + let st_head = search_term[0..cursor_pos].to_string(); + let st_tail = search_term[cursor_pos..search_term.len()].to_string(); + let st_cursor = "|".to_string(); + write!(f, "{}{}{}", st_head, st_cursor, st_tail) + } else { + let cursor = "|".to_string(); + write!(f, "{}{}", search_term, cursor) + } + } + + fn format_fuzzy_select_prompt_item( + &self, + f: &mut dyn std::fmt::Write, + text: &str, + active: bool, + highlight_matches: bool, + matcher: &SkimMatcherV2, + search_term: &str, + ) -> std::fmt::Result { + write!(f, "{} ", if active { ">" } else { " " }.magenta().bold())?; + + if highlight_matches { + if let Some((_score, indices)) = matcher.fuzzy_indices(text, search_term) { + for (idx, c) in text.chars().into_iter().enumerate() { + if indices.contains(&idx) { + write!(f, "{}", c.bold())?; + } else { + write!(f, "{}", c)?; + } + } + + return Ok(()); + } + } + + write!(f, "{}", text) + } } diff --git a/src/internal/error.rs b/src/internal/error.rs index 68ec955..cde2eab 100644 --- a/src/internal/error.rs +++ b/src/internal/error.rs @@ -18,6 +18,7 @@ pub enum AppError { BuildError { pkg_name: String }, UserCancellation, MissingDependencies(Vec), + MakePkg(String), } impl Display for AppError { @@ -33,6 +34,7 @@ impl Display for AppError { AppError::MissingDependencies(deps) => { write!(f, "Missing dependencies {}", deps.join(", ")) } + AppError::MakePkg(msg) => write!(f, "Failed to ru makepkg {msg}"), } } } diff --git a/src/internal/utils.rs b/src/internal/utils.rs index 4f945b9..2d1b640 100644 --- a/src/internal/utils.rs +++ b/src/internal/utils.rs @@ -5,7 +5,7 @@ use std::process::exit; use directories::ProjectDirs; use textwrap::wrap; -use crate::internal::exit_code::AppExitCode; +use crate::{internal::exit_code::AppExitCode, logging::get_logger}; use lazy_static::lazy_static; use super::error::{AppError, SilentUnwrap}; @@ -31,7 +31,9 @@ macro_rules! cancelled { /// Logs a message and exits the program with the given exit code. pub fn log_and_crash(msg: String, exit_code: AppExitCode) -> ! { - tracing::error!(msg); + get_logger().reset_output_type(); + get_logger().log_error(msg); + get_logger().flush(); exit(exit_code as i32); } diff --git a/src/logging/handler.rs b/src/logging/handler.rs index 9cf0c22..0e9ff6b 100644 --- a/src/logging/handler.rs +++ b/src/logging/handler.rs @@ -3,6 +3,7 @@ use indicatif::{MultiProgress, ProgressBar, ProgressDrawTarget}; use parking_lot::{Mutex, RwLock}; use std::{ fmt::Display, + io::{self, Write}, mem, sync::{atomic::AtomicBool, Arc}, time::Duration, @@ -10,7 +11,7 @@ use std::{ use crate::{internal::utils::wrap_text, uwu}; -use super::Verbosity; +use super::{get_logger, Verbosity}; const OK_SYMBOL: &str = "❖"; const ERR_SYMBOL: &str = "X"; @@ -46,6 +47,8 @@ pub enum OutputType { }, } +pub struct SuspendHandle; + impl LogHandler { pub fn log_error(&self, msg: String) { if self.is_loggable(Verbosity::Error) { @@ -101,7 +104,7 @@ impl LogHandler { } pub fn print_newline(&self) { - self.log(String::from("\n")) + self.log(String::from("")) } pub fn set_verbosity(&self, level: Verbosity) { @@ -112,7 +115,8 @@ impl LogHandler { self.set_output_type(OutputType::Stdout); } - pub fn suspend(&self) { + #[must_use] + pub fn suspend(&self) -> SuspendHandle { let mut output_type = self.output_type.write(); let mut old_output_type = OutputType::Stdout; mem::swap(&mut *output_type, &mut old_output_type); @@ -120,7 +124,9 @@ impl LogHandler { (*output_type) = OutputType::Buffer { buffer: Arc::new(Mutex::new(Vec::new())), suspended: Box::new(old_output_type), - } + }; + + SuspendHandle } pub fn unsuspend(&self) { @@ -194,6 +200,17 @@ impl LogHandler { (*self.level.read()) >= level } + /// Flushes the output buffer + pub fn flush(&self) { + let output = self.output_type.read(); + match &*output { + OutputType::Stdout => io::stdout().flush().unwrap(), + OutputType::Stderr => io::stderr().flush().unwrap(), + OutputType::Progress(p) => p.tick(), + _ => {} + } + } + fn preformat_msg(&self, msg: String) -> String { let msg = self.apply_uwu(msg); @@ -224,3 +241,9 @@ impl LogHandler { }; } } + +impl Drop for SuspendHandle { + fn drop(&mut self) { + get_logger().unsuspend(); + } +} diff --git a/src/operations/aur_install/aur_review.rs b/src/operations/aur_install/aur_review.rs index 841a778..589140c 100644 --- a/src/operations/aur_install/aur_review.rs +++ b/src/operations/aur_install/aur_review.rs @@ -1,3 +1,5 @@ +use tokio::fs; + use crate::{ builder::pager::PagerBuilder, internal::{ @@ -6,7 +8,7 @@ use crate::{ structs::Options, utils::get_cache_dir, }, - multi_select, prompt, + multi_select, newline, prompt, select_opt, }; use super::{repo_dependency_installation::RepoDependencyInstallation, BuildContext}; @@ -25,8 +27,7 @@ impl AurReview { let to_review = multi_select!(&self.packages, "Select packages to review"); for pkg in to_review.into_iter().filter_map(|i| self.packages.get(i)) { - let pkgbuild_path = get_cache_dir().join(pkg).join("PKGBUILD"); - PagerBuilder::default().path(pkgbuild_path).open().await?; + self.review_single_package(pkg).await?; } if !prompt!(default yes, "Do you still want to install those packages?") { return Err(AppError::UserCancellation); @@ -38,4 +39,39 @@ impl AurReview { contexts: self.contexts, }) } + + async fn review_single_package(&self, pkg: &str) -> AppResult<()> { + newline!(); + tracing::info!("Reviewing {pkg}"); + let mut files_iter = fs::read_dir(get_cache_dir().join(pkg)).await?; + let mut files = Vec::new(); + + while let Some(file) = files_iter.next_entry().await? { + let path = file.path(); + + if path.is_file() { + files.push(file.path()); + } + } + + let file_names = files + .iter() + .map(|f| f.file_name().unwrap()) + .map(|f| f.to_string_lossy()) + .collect::>(); + + while let Some(selection) = select_opt!(&file_names, "Select a file to review") { + if let Some(path) = files.get(selection) { + if let Err(e) = PagerBuilder::default().path(path).open().await { + tracing::debug!("Pager error {e}"); + } + } else { + break; + } + } + + tracing::info!("Done reviewing {pkg}"); + + Ok(()) + } } diff --git a/src/operations/aur_install/mod.rs b/src/operations/aur_install/mod.rs index 60eeaae..eb12fa9 100644 --- a/src/operations/aur_install/mod.rs +++ b/src/operations/aur_install/mod.rs @@ -114,6 +114,9 @@ pub async fn aur_install(packages: Vec, options: Options) { deps.join(", ") ) } + AppError::MakePkg(msg) => { + crash!(AppExitCode::MakePkgError, "makepgk failed {msg}") + } _ => crash!(AppExitCode::Other, "Unknown error"), } }