diff --git a/Cargo.lock b/Cargo.lock index 075a712032..45ab8ebe26 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1566,6 +1566,7 @@ dependencies = [ "serde_urlencoded 0.6.1 (registry+https://github.com/rust-lang/crates.io-index)", "serde_yaml 0.8.9 (registry+https://github.com/rust-lang/crates.io-index)", "shellexpand 1.0.0 (registry+https://github.com/rust-lang/crates.io-index)", + "sublime_fuzzy 0.5.0 (registry+https://github.com/rust-lang/crates.io-index)", "subprocess 0.1.18 (registry+https://github.com/rust-lang/crates.io-index)", "surf 1.0.2 (registry+https://github.com/rust-lang/crates.io-index)", "syntect 3.2.0 (registry+https://github.com/rust-lang/crates.io-index)", @@ -2443,6 +2444,11 @@ name = "strsim" version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" +[[package]] +name = "sublime_fuzzy" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" + [[package]] name = "subprocess" version = "0.1.18" @@ -3247,6 +3253,7 @@ dependencies = [ "checksum stackvector 1.0.6 (registry+https://github.com/rust-lang/crates.io-index)" = "1c4725650978235083241fab0fdc8e694c3de37821524e7534a1a9061d1068af" "checksum static_assertions 0.3.3 (registry+https://github.com/rust-lang/crates.io-index)" = "b4f8de36da215253eb5f24020bfaa0646613b48bf7ebe36cdfa37c3b3b33b241" "checksum strsim 0.8.0 (registry+https://github.com/rust-lang/crates.io-index)" = "8ea5119cdb4c55b55d432abb513a0429384878c15dde60cc77b1c99de1a95a6a" +"checksum sublime_fuzzy 0.5.0 (registry+https://github.com/rust-lang/crates.io-index)" = "97bd7ad698ea493a3a7f60c2ffa117c234f341e09f8cc2d39cef10cdde077acf" "checksum subprocess 0.1.18 (registry+https://github.com/rust-lang/crates.io-index)" = "28fc0f40f0c0da73339d347aa7d6d2b90341a95683a47722bc4eebed71ff3c00" "checksum surf 1.0.2 (registry+https://github.com/rust-lang/crates.io-index)" = "018eed64aede455beb88505d50c5c64882bebbe0996d4b660c272e3d8bb6f883" "checksum syn 0.15.43 (registry+https://github.com/rust-lang/crates.io-index)" = "ee06ea4b620ab59a2267c6b48be16244a3389f8bfa0986bdd15c35b890b00af3" diff --git a/Cargo.toml b/Cargo.toml index cfe107e9be..e81bc0ee69 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -73,6 +73,7 @@ num-bigint = { version = "0.2.3", features = ["serde"] } bigdecimal = { version = "0.1.0", features = ["serde"] } natural = "0.3.0" serde_urlencoded = "0.6.1" +sublime_fuzzy = "0.5" neso = { version = "0.5.0", optional = true } crossterm = { version = "0.10.2", optional = true } diff --git a/src/cli.rs b/src/cli.rs index 8ed2b9bd55..fb158b1af6 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -9,6 +9,7 @@ use crate::commands::whole_stream_command; use crate::context::Context; use crate::data::Value; pub(crate) use crate::errors::ShellError; +use crate::fuzzysearch::{interactive_fuzzy_search, SelectionResult}; use crate::git::current_branch; use crate::parser::registry::Signature; use crate::parser::{hir, CallNode, Pipeline, PipelineElement, TokenNode}; @@ -333,14 +334,47 @@ pub async fn cli() -> Result<(), Box> { rl.set_edit_mode(edit_mode); - let readline = rl.readline(&format!( + // Register Ctrl-r for history fuzzy search + // rustyline doesn't support custom commands, so we override Ctrl-D (EOF) + #[cfg(not(windows))] // https://github.com/nushell/nushell/issues/689 + rl.bind_sequence(rustyline::KeyPress::Ctrl('R'), rustyline::Cmd::EndOfFile); + // Redefine Ctrl-D to same command as Ctrl-C + rl.bind_sequence(rustyline::KeyPress::Ctrl('D'), rustyline::Cmd::Interrupt); + + let prompt = &format!( "{}{}> ", cwd, match current_branch() { Some(s) => format!("({})", s), None => "".to_string(), } - )); + ); + let mut initial_command = Some(String::new()); + let mut readline = Err(ReadlineError::Eof); + while let Some(ref cmd) = initial_command { + readline = rl.readline_with_initial(prompt, (&cmd, "")); + if let Err(ReadlineError::Eof) = &readline { + // Fuzzy search in history + let lines = rl.history().iter().rev().map(|s| s.as_str()).collect(); + let selection = interactive_fuzzy_search(&lines, 5); // Clears last line with prompt + match selection { + SelectionResult::Selected(line) => { + println!("{}{}", &prompt, &line); // TODO: colorize prompt + readline = Ok(line.clone()); + initial_command = None; + } + SelectionResult::Edit(line) => { + initial_command = Some(line); + } + SelectionResult::NoSelection => { + readline = Ok("".to_string()); + initial_command = None; + } + } + } else { + initial_command = None; + } + } match process_line(readline, &mut context).await { LineResult::Success(line) => { diff --git a/src/fuzzysearch.rs b/src/fuzzysearch.rs new file mode 100644 index 0000000000..b27745590e --- /dev/null +++ b/src/fuzzysearch.rs @@ -0,0 +1,184 @@ +use ansi_term::{ANSIString, ANSIStrings, Colour, Style}; +use crossterm::{cursor, terminal, ClearType, InputEvent, KeyEvent, RawScreen}; +use std::io::Write; +use sublime_fuzzy::best_match; + +pub enum SelectionResult { + Selected(String), + Edit(String), + NoSelection, +} + +pub fn interactive_fuzzy_search(lines: &Vec<&str>, max_results: usize) -> SelectionResult { + #[derive(PartialEq)] + enum State { + Selecting, + Quit, + Selected(String), + Edit(String), + } + let mut state = State::Selecting; + if let Ok(_raw) = RawScreen::into_raw_mode() { + // User input for search + let mut searchinput = String::new(); + let mut selected = 0; + + let mut cursor = cursor(); + let _ = cursor.hide(); + let input = crossterm::input(); + let mut sync_stdin = input.read_sync(); + + while state == State::Selecting { + let mut selected_lines = fuzzy_search(&searchinput, &lines, max_results); + let num_lines = selected_lines.len(); + paint_selection_list(&selected_lines, selected); + if let Some(ev) = sync_stdin.next() { + match ev { + InputEvent::Keyboard(k) => match k { + KeyEvent::Esc | KeyEvent::Ctrl('c') => { + state = State::Quit; + } + KeyEvent::Up => { + if selected > 0 { + selected -= 1; + } + } + KeyEvent::Down => { + if selected + 1 < selected_lines.len() { + selected += 1; + } + } + KeyEvent::Char('\n') => { + state = if selected_lines.len() > 0 { + State::Selected(selected_lines.remove(selected).text) + } else { + State::Edit("".to_string()) + }; + } + KeyEvent::Char('\t') | KeyEvent::Right => { + state = if selected_lines.len() > 0 { + State::Edit(selected_lines.remove(selected).text) + } else { + State::Edit("".to_string()) + }; + } + KeyEvent::Char(ch) => { + searchinput.push(ch); + selected = 0; + } + KeyEvent::Backspace => { + searchinput.pop(); + selected = 0; + } + _ => { + // println!("OTHER InputEvent: {:?}", k); + } + }, + _ => {} + } + } + if num_lines > 0 { + cursor.move_up(num_lines as u16); + } + } + let (_x, y) = cursor.pos(); + let _ = cursor.goto(0, y - 1); + let _ = cursor.show(); + let _ = RawScreen::disable_raw_mode(); + } + terminal().clear(ClearType::FromCursorDown).unwrap(); + + match state { + State::Selected(line) => SelectionResult::Selected(line), + State::Edit(line) => SelectionResult::Edit(line), + _ => SelectionResult::NoSelection, + } +} + +pub struct Match { + text: String, + char_matches: Vec<(usize, usize)>, +} + +pub fn fuzzy_search(searchstr: &str, lines: &Vec<&str>, max_results: usize) -> Vec { + if searchstr.is_empty() { + return lines + .iter() + .take(max_results) + .map(|line| Match { + text: line.to_string(), + char_matches: Vec::new(), + }) + .collect(); + } + + let mut matches = lines + .iter() + .enumerate() + .map(|(idx, line)| (idx, best_match(&searchstr, line))) + .filter(|(_i, m)| m.is_some()) + .map(|(i, m)| (i, m.unwrap())) + .collect::>(); + matches.sort_by(|a, b| b.1.score().cmp(&a.1.score())); + + let results: Vec = matches + .iter() + .take(max_results) + .map(|(i, m)| Match { + text: lines[*i].to_string(), + char_matches: m.continuous_matches(), + }) + .collect(); + results +} + +fn highlight(textmatch: &Match, normal: Style, highlighted: Style) -> Vec { + let text = &textmatch.text; + let mut ansi_strings = vec![]; + let mut idx = 0; + for (match_idx, len) in &textmatch.char_matches { + ansi_strings.push(normal.paint(&text[idx..*match_idx])); + idx = match_idx + len; + ansi_strings.push(highlighted.paint(&text[*match_idx..idx])); + } + if idx < text.len() { + ansi_strings.push(normal.paint(&text[idx..text.len()])); + } + ansi_strings +} + +fn paint_selection_list(lines: &Vec, selected: usize) { + let terminal = terminal(); + let size = terminal.terminal_size(); + let width = size.0 as usize; + let cursor = cursor(); + let (_x, y) = cursor.pos(); + for (i, line) in lines.iter().enumerate() { + let _ = cursor.goto(0, y + (i as u16)); + let (style, highlighted) = if selected == i { + (Colour::White.normal(), Colour::Cyan.normal()) + } else { + (Colour::White.dimmed(), Colour::Cyan.normal()) + }; + let mut ansi_strings = highlight(line, style, highlighted); + for _ in line.text.len()..width { + ansi_strings.push(style.paint(' '.to_string())); + } + println!("{}", ANSIStrings(&ansi_strings)); + } + let _ = cursor.goto(0, y + (lines.len() as u16)); + print!( + "{}", + Colour::Blue.paint("[ESC to quit, Enter to execute, Tab to edit]") + ); + + let _ = std::io::stdout().flush(); + // Clear additional lines from previous selection + terminal.clear(ClearType::FromCursorDown).unwrap(); +} + +#[test] +fn fuzzy_match() { + let matches = fuzzy_search("cb", &vec!["abc", "cargo build"], 1); + assert_eq!(matches[0].text, "cargo build"); +} diff --git a/src/lib.rs b/src/lib.rs index 1aedc2e11f..f4ccb4e4e3 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -12,6 +12,7 @@ mod env; mod errors; mod evaluate; mod format; +mod fuzzysearch; mod git; mod parser; mod plugin;