diff --git a/Cargo.lock b/Cargo.lock index 410254a42..4b71f10bc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1045,6 +1045,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "59c6f2989294b9a498d3ad5491a79c6deb604617378e1cdc4bfc1c1361fe2f87" dependencies = [ "console", + "fuzzy-matcher", "shell-words", ] diff --git a/crates/nu-command/Cargo.toml b/crates/nu-command/Cargo.toml index ce33ecf93..c96ace9af 100644 --- a/crates/nu-command/Cargo.toml +++ b/crates/nu-command/Cargo.toml @@ -38,12 +38,15 @@ base64 = "0.21.0" byteorder = "1.4.3" bytesize = "1.2.0" calamine = "0.19.1" -chrono = { version = "0.4.23", features = ["std", "unstable-locales"], default-features = false } +chrono = { version = "0.4.23", features = [ + "std", + "unstable-locales", +], default-features = false } chrono-humanize = "0.2.1" chrono-tz = "0.8.1" crossterm = "0.26" csv = "1.2.0" -dialoguer = { default-features = false, version = "0.10.3" } +dialoguer = { default-features = false, features = ["fuzzy-select"], version = "0.10.3" } digest = { default-features = false, version = "0.10.0" } dtparse = "1.4.0" encoding_rs = "0.8.30" @@ -73,7 +76,12 @@ quick-xml = "0.28" rand = "0.8" rayon = "1.7.0" regex = "1.7.1" -ureq = { version = "2.6.2", default-features = false, features = ["json", "charset", "native-tls", "gzip"] } +ureq = { version = "2.6.2", default-features = false, features = [ + "json", + "charset", + "native-tls", + "gzip", +] } native-tls = "0.2.11" roxmltree = "0.18.0" rust-embed = "6.6.0" @@ -152,7 +160,9 @@ version = "0.48.0" [features] dataframe = ["num", "polars", "sqlparser"] plugin = ["nu-parser/plugin"] -sqlite = ["rusqlite"] # TODO: given that rusqlite is included in reedline, should we just always include it? +sqlite = [ + "rusqlite", +] # TODO: given that rusqlite is included in reedline, should we just always include it? trash-support = ["trash"] which-support = ["which"] diff --git a/crates/nu-command/src/default_context.rs b/crates/nu-command/src/default_context.rs index e22d1fdae..3e9b6b65a 100644 --- a/crates/nu-command/src/default_context.rs +++ b/crates/nu-command/src/default_context.rs @@ -257,6 +257,7 @@ pub fn create_default_context() -> EngineState { Clear, Du, Input, + InputList, Kill, Sleep, TermSize, diff --git a/crates/nu-command/src/platform/input.rs b/crates/nu-command/src/platform/input/input_.rs similarity index 100% rename from crates/nu-command/src/platform/input.rs rename to crates/nu-command/src/platform/input/input_.rs diff --git a/crates/nu-command/src/platform/input/list.rs b/crates/nu-command/src/platform/input/list.rs new file mode 100644 index 000000000..8cc39600a --- /dev/null +++ b/crates/nu-command/src/platform/input/list.rs @@ -0,0 +1,247 @@ +use dialoguer::{console::Term, Select}; +use dialoguer::{FuzzySelect, MultiSelect}; +use nu_ansi_term::Color; +use nu_engine::CallExt; +use nu_protocol::ast::Call; +use nu_protocol::engine::{Command, EngineState, Stack}; +use nu_protocol::{ + Category, Example, IntoPipelineData, PipelineData, ShellError, Signature, SyntaxShape, Type, + Value, +}; +use std::fmt::{Display, Formatter}; + +enum InteractMode { + Single(Option), + Multi(Option>), +} + +#[derive(Clone)] +struct Options { + name: String, + value: Value, +} + +impl Display for Options { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.name) + } +} + +#[derive(Clone)] +pub struct InputList; + +const INTERACT_ERROR: &str = "Interact error, could not process options"; + +impl Command for InputList { + fn name(&self) -> &str { + "input list" + } + + fn signature(&self) -> Signature { + Signature::build("input list") + .input_output_types(vec![( + Type::List(Box::new(Type::Any)), + Type::List(Box::new(Type::Any)), + )]) + .optional("prompt", SyntaxShape::String, "the prompt to display") + .switch( + "multi", + "Use multiple results, you can press a to toggle all options on/off", + Some('m'), + ) + .switch("fuzzy", "Use a fuzzy select.", Some('f')) + .allow_variants_without_examples(true) + .category(Category::Platform) + } + + fn usage(&self) -> &str { + "Interactive list selection." + } + + fn extra_usage(&self) -> &str { + "Abort with esc or q." + } + + fn search_terms(&self) -> Vec<&str> { + vec!["prompt", "ask", "menu"] + } + + fn run( + &self, + engine_state: &EngineState, + stack: &mut Stack, + call: &Call, + input: PipelineData, + ) -> Result { + let head = call.head; + let prompt: Option = call.opt(engine_state, stack, 0)?; + + let options: Vec = match input { + PipelineData::Value(Value::Range { .. }, ..) + | PipelineData::Value(Value::List { .. }, ..) + | PipelineData::ListStream { .. } + | PipelineData::Value(Value::Record { .. }, ..) => input + .into_iter() + .map_while(move |x| { + if let Ok(val) = x.as_string() { + Some(Options { + name: val, + value: x, + }) + } else if let Ok(record) = x.as_record() { + let mut options = Vec::new(); + for (col, val) in record.0.iter().zip(record.1.iter()) { + if let Ok(val) = val.as_string() { + options.push(format!( + " {}{}{}: {} |\t", + Color::Cyan.prefix(), + col, + Color::Cyan.suffix(), + &val + )); + } + } + Some(Options { + name: options.join(""), + value: x, + }) + } else { + None + } + }) + .collect(), + + _ => { + return Err(ShellError::TypeMismatch { + err_message: "expected a list or table".to_string(), + span: head, + }) + } + }; + let prompt = prompt.unwrap_or_default(); + + if options.is_empty() { + return Err(ShellError::TypeMismatch { + err_message: "expected a list or table, it can also be a problem with the an inner type of your list.".to_string(), + span: head, + }); + } + + // could potentially be used to map the use theme colors at some point + // let theme = dialoguer::theme::ColorfulTheme { + // active_item_style: Style::new().fg(Color::Cyan).bold(), + // ..Default::default() + // }; + + let ans: InteractMode = if call.has_flag("multi") { + if call.has_flag("fuzzy") { + return Err(ShellError::TypeMismatch { + err_message: "Fuzzy search is not supported for multi select".to_string(), + span: head, + }); + } else { + let mut multi_select = MultiSelect::new(); //::with_theme(&theme); + + InteractMode::Multi( + if !prompt.is_empty() { + multi_select.with_prompt(&prompt) + } else { + &mut multi_select + } + .items(&options) + .report(false) + .interact_on_opt(&Term::stderr()) + .map_err(|err| ShellError::IOError(format!("{}: {}", INTERACT_ERROR, err)))?, + ) + } + } else if call.has_flag("fuzzy") { + let mut fuzzy_select = FuzzySelect::new(); //::with_theme(&theme); + + InteractMode::Single( + if !prompt.is_empty() { + fuzzy_select.with_prompt(&prompt) + } else { + &mut fuzzy_select + } + .items(&options) + .default(0) + .report(false) + .interact_on_opt(&Term::stderr()) + .map_err(|err| ShellError::IOError(format!("{}: {}", INTERACT_ERROR, err)))?, + ) + } else { + let mut select = Select::new(); //::with_theme(&theme); + InteractMode::Single( + if !prompt.is_empty() { + select.with_prompt(&prompt) + } else { + &mut select + } + .items(&options) + .default(0) + .report(false) + .interact_on_opt(&Term::stderr()) + .map_err(|err| ShellError::IOError(format!("{}: {}", INTERACT_ERROR, err)))?, + ) + }; + + match ans { + InteractMode::Multi(res) => Ok({ + match res { + Some(opts) => Value::List { + vals: opts.iter().map(|s| options[*s].value.clone()).collect(), + span: head, + }, + None => Value::List { + vals: vec![], + span: head, + }, + } + } + .into_pipeline_data()), + InteractMode::Single(res) => Ok({ + match res { + Some(opt) => options[opt].value.clone(), + + None => Value::String { + val: "".to_string(), + span: head, + }, + } + } + .into_pipeline_data()), + } + } + + fn examples(&self) -> Vec { + vec![ + Example { + description: "Return a single value from a list", + example: r#"[1 2 3 4 5] | input list 'Rate it'"#, + result: None, + }, + Example { + description: "Return multiple values from a list", + example: r#"[Banana Kiwi Pear Peach Strawberry] | input list -m 'Add fruits to the basket'"#, + result: None, + }, + Example { + description: "Return a single record from a table with fuzzy search", + example: r#"ls | input list -f 'Select the target'"#, + result: None, + }, + ] + } +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn test_examples() { + use crate::test_examples; + + test_examples(InputList {}) + } +} diff --git a/crates/nu-command/src/platform/input/mod.rs b/crates/nu-command/src/platform/input/mod.rs new file mode 100644 index 000000000..994a6d3e7 --- /dev/null +++ b/crates/nu-command/src/platform/input/mod.rs @@ -0,0 +1,5 @@ +mod input_; +mod list; + +pub use input_::Input; +pub use list::InputList; diff --git a/crates/nu-command/src/platform/mod.rs b/crates/nu-command/src/platform/mod.rs index 46a5f898b..9756ccc96 100644 --- a/crates/nu-command/src/platform/mod.rs +++ b/crates/nu-command/src/platform/mod.rs @@ -12,6 +12,7 @@ pub use clear::Clear; pub use dir_info::{DirBuilder, DirInfo, FileInfo}; pub use du::Du; pub use input::Input; +pub use input::InputList; pub use kill::Kill; pub use sleep::Sleep; pub use term_size::TermSize;