diff options
author | Aram Drevekenin <aram@poor.dev> | 2023-05-05 17:30:56 +0200 |
---|---|---|
committer | Aram Drevekenin <aram@poor.dev> | 2023-05-05 17:30:56 +0200 |
commit | b739b8303e7d8a2f583430f830ba216dd53fbccd (patch) | |
tree | 6d669bf944e759766cca07260766c4fb5830e995 | |
parent | acb31c5322b1ffb30420cb6171f50a8e5944d013 (diff) |
mvp of strider fuzzy find
-rw-r--r-- | Cargo.lock | 44 | ||||
-rw-r--r-- | default-plugins/strider/Cargo.toml | 6 | ||||
-rw-r--r-- | default-plugins/strider/src/main.rs | 129 | ||||
-rw-r--r-- | default-plugins/strider/src/search.rs | 300 | ||||
-rw-r--r-- | default-plugins/strider/src/state.rs | 83 | ||||
-rw-r--r-- | zellij-server/src/logging_pipe.rs | 3 | ||||
-rw-r--r-- | zellij-server/src/plugins/mod.rs | 28 | ||||
-rw-r--r-- | zellij-server/src/plugins/plugin_loader.rs | 92 | ||||
-rw-r--r-- | zellij-server/src/plugins/plugin_map.rs | 63 | ||||
-rw-r--r-- | zellij-server/src/plugins/wasm_bridge.rs | 71 | ||||
-rw-r--r-- | zellij-server/src/plugins/zellij_exports.rs | 58 | ||||
-rw-r--r-- | zellij-tile/src/lib.rs | 31 | ||||
-rw-r--r-- | zellij-tile/src/shim.rs | 24 | ||||
-rwxr-xr-x | zellij-utils/assets/plugins/compact-bar.wasm | bin | 490119 -> 796375 bytes | |||
-rwxr-xr-x | zellij-utils/assets/plugins/status-bar.wasm | bin | 621069 -> 921497 bytes | |||
-rwxr-xr-x | zellij-utils/assets/plugins/strider.wasm | bin | 503181 -> 908672 bytes | |||
-rwxr-xr-x | zellij-utils/assets/plugins/tab-bar.wasm | bin | 458835 -> 765594 bytes | |||
-rw-r--r-- | zellij-utils/src/data.rs | 4 | ||||
-rw-r--r-- | zellij-utils/src/errors.rs | 2 |
19 files changed, 907 insertions, 31 deletions
diff --git a/Cargo.lock b/Cargo.lock index 55f2bca7d..64381f371 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1042,6 +1042,15 @@ dependencies = [ ] [[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 = "generational-arena" version = "0.2.8" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2386,6 +2395,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f3f6f92acf49d1b98f7a81226834412ada05458b7364277387724a237f062695" [[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] name = "scopeguard" version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2740,8 +2758,14 @@ checksum = "213701ba3370744dcd1a12960caa4843b3d68b4d1c0a5d575e0d65b2ee9d16c0" name = "strider" version = "0.2.0" dependencies = [ + "ansi_term", "colored", + "fuzzy-matcher", "pretty-bytes", + "serde", + "serde_json", + "unicode-width", + "walkdir", "zellij-tile", ] @@ -3004,6 +3028,16 @@ dependencies = [ ] [[package]] +name = "thread_local" +version = "1.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fdd6f064ccff2d6567adcb3873ca630700f00b5ad3f060c25b5dcfd9a4ce152" +dependencies = [ + "cfg-if 1.0.0", + "once_cell", +] + +[[package]] name = "time" version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -3309,6 +3343,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d5b2c62b4012a3e1eca5a7e077d13b3bf498c4073e33ccd58626607748ceeca" [[package]] +name = "walkdir" +version = "2.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36df944cda56c7d8d8b7496af378e6b16de9284591917d307c9b4d313c44e698" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] name = "wasi" version = "0.9.0+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" diff --git a/default-plugins/strider/Cargo.toml b/default-plugins/strider/Cargo.toml index f7801bfaf..dcfc4f169 100644 --- a/default-plugins/strider/Cargo.toml +++ b/default-plugins/strider/Cargo.toml @@ -10,3 +10,9 @@ license = "MIT" colored = "2.0.0" zellij-tile = { path = "../../zellij-tile" } pretty-bytes = "0.2.2" +walkdir = "2.3.3" +fuzzy-matcher = "0.3.7" +serde = "*" # TODO: version number! +serde_json = "*" # TODO: no!! from zellij-tile! +unicode-width = "*" # TODO: version number! +ansi_term = "*" # TODO: version number! diff --git a/default-plugins/strider/src/main.rs b/default-plugins/strider/src/main.rs index 6868c6706..f1a69d065 100644 --- a/default-plugins/strider/src/main.rs +++ b/default-plugins/strider/src/main.rs @@ -1,16 +1,47 @@ +// TODO: +// 1. worker to different file - DONE +// 2. separate search rendering to different rendering function - DONE +// 3. make hd scanning happen on startup and show loading indication - DONE +// 4. make selection and opening files work - TODO: CONTINUE HERE (04/05) mod state; +mod search; use colored::*; -use state::{refresh_directory, FsEntry, State}; +use state::{refresh_directory, FsEntry, State, CURRENT_SEARCH_TERM}; +use search::{SearchWorker, ResultsOfSearch}; use std::{cmp::min, time::Instant}; use zellij_tile::prelude::*; +use serde_json; register_plugin!(State); +thread_local! { + static SEARCH_WORKER: std::cell::RefCell<SearchWorker> = std::cell::RefCell::new(SearchWorker::new()); +} + +#[no_mangle] +pub fn search_worker() { + let mut json = String::new(); + std::io::stdin().read_line(&mut json).unwrap(); + let (message, payload): (String, String) = serde_json::from_str(&json).unwrap(); // TODO: no unwrap + SEARCH_WORKER.with(|search_worker| { + search_worker.borrow_mut().on_message(message, payload); + }); +} + impl ZellijPlugin for State { fn load(&mut self) { refresh_directory(self); - subscribe(&[EventType::Key, EventType::Mouse]); + self.loading = true; + subscribe(&[ + EventType::Key, + EventType::Mouse, + EventType::CustomMessage, + EventType::Timer, + ]); + eprintln!("post_message_to search scan_folder"); + post_message_to("search", String::from("scan_folder"), String::new()); + set_timeout(0.5); // for displaying loading animation } fn update(&mut self, event: Event) -> bool { @@ -22,26 +53,97 @@ impl ZellijPlugin for State { }; self.ev_history.push_back((event.clone(), Instant::now())); match event { + Event::Timer(_elapsed) => { + // eprintln!("got timer event"); + should_render = true; + if self.loading { + set_timeout(0.5); + if self.loading_animation_offset == u8::MAX { + self.loading_animation_offset = 0; + } else { + self.loading_animation_offset = self.loading_animation_offset.saturating_add(1); + } + } + } + Event::CustomMessage(message, payload) => { + match message.as_str() { + "update_search_results" => { + if let Ok(mut results_of_search) = serde_json::from_str::<ResultsOfSearch>(&payload) { + if Some(results_of_search.search_term) == self.search_term { + self.search_results = results_of_search.search_results.drain(..).collect(); + should_render = true; + } + } + }, + "done_scanning_folder" => { + eprintln!("done_scanning_folder"); + self.loading = false; + should_render = true; + }, + _ => {} + } + } Event::Key(key) => match key { + // modes: + // 1. typing_search_term + // 2. exploring_search_results + // 3. normal + Key::Esc | Key::Char('\n') if self.typing_search_term() => { + self.accept_search_term(); + } + _ if self.typing_search_term() => { + self.append_to_search_term(key); + if let Some(search_term) = self.search_term.as_ref() { + std::fs::write(CURRENT_SEARCH_TERM, search_term.as_bytes()).unwrap(); + post_message_to("search", String::from("search"), String::from(&self.search_term.clone().unwrap())); + } + should_render = true; + } + Key::Esc if self.exploring_search_results() => { + self.stop_exploring_search_results(); + should_render = true; + } + Key::Char('/') => { + self.start_typing_search_term(); + should_render = true; + } + Key::Esc => { + self.stop_typing_search_term(); + should_render = true; + } Key::Up | Key::Char('k') => { - let currently_selected = self.selected(); - *self.selected_mut() = self.selected().saturating_sub(1); - if currently_selected != self.selected() { + if self.exploring_search_results() { + self.move_search_selection_up(); should_render = true; + } else { + let currently_selected = self.selected(); + *self.selected_mut() = self.selected().saturating_sub(1); + if currently_selected != self.selected() { + should_render = true; + } } }, Key::Down | Key::Char('j') => { - let currently_selected = self.selected(); - let next = self.selected().saturating_add(1); - *self.selected_mut() = min(self.files.len().saturating_sub(1), next); - if currently_selected != self.selected() { + if self.exploring_search_results() { + self.move_search_selection_down(); should_render = true; + } else { + let currently_selected = self.selected(); + let next = self.selected().saturating_add(1); + *self.selected_mut() = min(self.files.len().saturating_sub(1), next); + if currently_selected != self.selected() { + should_render = true; + } } }, Key::Right | Key::Char('\n') | Key::Char('l') if !self.files.is_empty() => { + if self.exploring_search_results() { + self.open_search_result(); + } else { + self.traverse_dir_or_open_file(); + self.ev_history.clear(); + } should_render = true; - self.traverse_dir_or_open_file(); - self.ev_history.clear(); }, Key::Left | Key::Char('h') => { if self.path.components().count() > 2 { @@ -111,6 +213,11 @@ impl ZellijPlugin for State { } fn render(&mut self, rows: usize, cols: usize) { + + if self.typing_search_term() || self.exploring_search_results() { + return self.render_search(rows, cols); + } + for i in 0..rows { if self.selected() < self.scroll() { *self.scroll_mut() = self.selected(); diff --git a/default-plugins/strider/src/search.rs b/default-plugins/strider/src/search.rs new file mode 100644 index 000000000..c7cc021dd --- /dev/null +++ b/default-plugins/strider/src/search.rs @@ -0,0 +1,300 @@ +use crate::state::{State, ROOT, CURRENT_SEARCH_TERM}; + +use zellij_tile::prelude::*; +use unicode_width::UnicodeWidthStr; + +use walkdir::WalkDir; +use fuzzy_matcher::FuzzyMatcher; +use fuzzy_matcher::skim::SkimMatcherV2; +use serde::{Serialize, Deserialize}; + +use std::io::{ self, BufRead }; + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub enum SearchResult { + File { + path: String, + score: i64, + indices: Vec<usize>, + }, + LineInFile { + path: String, + line: String, + line_number: usize, + score: i64, + indices: Vec<usize>, + } +} + +impl SearchResult { + pub fn new_file_name(score: i64, indices: Vec<usize>, path: String) -> Self { + SearchResult::File { + path, + score, + indices + } + } + pub fn new_file_line(score: i64, indices: Vec<usize>, path: String, line: String, line_number: usize) -> Self { + SearchResult::LineInFile { + path, + score, + indices, + line, + line_number, + } + } + pub fn score(&self) -> i64 { + match self { + SearchResult::File { score, .. } => *score, + SearchResult::LineInFile { score, .. } => *score, + } + } + pub fn rendered_height(&self) -> usize { + match self { + SearchResult::File { .. } => 1, + SearchResult::LineInFile { .. } => 2, + } + } + pub fn render(&self, max_width: usize, is_selected: bool) -> String { + match self { + SearchResult::File { path, indices, .. } => { + self.render_line_with_indices(path, indices, max_width, true, is_selected) + } + SearchResult::LineInFile { path, line, line_number, indices, .. } => { + let rendered_path = self.render_line_with_indices(path, &vec![], max_width, true, is_selected); // TODO: + // better + let line_indication_text = format!("LINE {}", line_number); // TODO: also truncate + // this + let rendered_file_line = self.render_line_with_indices(line, indices, max_width.saturating_sub(line_indication_text.width()), false, is_selected); + format!("{}\n{} {}", rendered_path, line_indication_text, rendered_file_line) + } + } + } + fn render_line_with_indices(&self, line_to_render: &String, indices: &Vec<usize>, max_width: usize, is_file_name: bool, is_selected: bool) -> String { + // TODO: get these from Zellij + let (green_code, orange_code) = if is_selected { + ("\u{1b}[48;5;102;38;5;154;1m", "\u{1b}[48;5;102;38;5;166;1m" ) + } else { + ("\u{1b}[38;5;154;1m", "\u{1b}[38;5;166;1m" ) + }; + + let green_bold_background_code = "\u{1b}[48;5;154;38;5;0;1m"; + let orange_bold_background_code = "\u{1b}[48;5;166;38;5;0;1m"; + let reset_code = "\u{1b}[m"; + let (line_color, index_color) = if is_file_name { + (orange_code, orange_bold_background_code) + } else { + (green_code, green_bold_background_code) + }; + let mut truncate_start_position = None; + let mut truncate_end_position = None; + if line_to_render.width() > max_width { + let length_of_each_half = max_width.saturating_sub(4) / 2; + truncate_start_position = Some(length_of_each_half); + truncate_end_position = Some(line_to_render.width().saturating_sub(length_of_each_half)); + } + let mut first_half = format!("{}{}", reset_code, line_color); + let mut second_half = format!("{}{}", reset_code, line_color); + for (i, character) in line_to_render.chars().enumerate() { + if (truncate_start_position.is_none() && truncate_end_position.is_none()) || Some(i) < truncate_start_position { + if indices.contains(&i) { + first_half.push_str(&format!("{}{}{}{}", index_color, character, reset_code, line_color)); // TODO: less allocations + } else { + first_half.push(character); + } + } else if Some(i) > truncate_end_position { + if indices.contains(&i) { + second_half.push_str(&format!("{}{}{}{}", index_color, character, reset_code, line_color)); // TODO: less allocations + } else { + second_half.push(character); + } + } + } + if let Some(_truncate_start_position) = truncate_start_position { + format!("{}[..]{}{}", first_half, second_half, reset_code) + } else { + format!("{}{}", first_half, reset_code) + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct ResultsOfSearch { + pub search_term: String, + pub search_results: Vec<SearchResult>, +} + +impl ResultsOfSearch { + pub fn new(search_term: String, search_results: Vec<SearchResult>) -> Self { + ResultsOfSearch { + search_term, + search_results, + } + } + pub fn limit_search_results(mut self, max_results: usize) -> Self { + self.search_results.sort_by(|a, b| b.score().cmp(&a.score())); + self.search_results = if self.search_results.len() > max_results { + self.search_results.drain(..max_results).collect() + } else { + self.search_results.drain(..).collect() + }; + self + } +} + +#[derive(Default, Serialize, Deserialize)] +pub struct SearchWorker { + pub search_paths: Vec<String>, + pub search_file_contents: Vec<(String, usize, String)>, // file_name, line_number, line + skip_hidden_files: bool, +} + +impl SearchWorker { + pub fn new() -> Self { + SearchWorker { + search_paths: vec![], + search_file_contents: vec![], + skip_hidden_files: true, + } + } + pub fn on_message(&mut self, message: String, payload: String) { + eprintln!("got message: {:?}, search_paths.len: {:?}", message, self.search_paths.len()); + match message.as_str() { // TODO: deserialize to type + "scan_folder" => { + if let Err(e) = std::fs::remove_file("/data/search_data") { + eprintln!("Warning: failed to remove cache file: {:?}", e); + } + self.populate_search_paths(); + eprintln!("search_paths length after populating in scan_folder: {:?}", self.search_paths.len()); + post_message_to_plugin("done_scanning_folder".into(), "".into()); + } + "search" => { + let search_term = payload; + let (search_term, matches) = self.search(search_term); + let search_results = ResultsOfSearch::new(search_term, matches).limit_search_results(100); + post_message_to_plugin("update_search_results".into(), serde_json::to_string(&search_results).unwrap()); + } + "skip_hidden_files" => { + match serde_json::from_str::<bool>(&payload) { + Ok(should_skip_hidden_files) => { + self.skip_hidden_files = should_skip_hidden_files; + }, + Err(e) => { + eprintln!("Failed to deserialize payload: {:?}", e); + } + } + } + _ => {} + } + } + fn search(&mut self, search_term: String) -> (String, Vec<SearchResult>) { + if self.search_paths.is_empty() { + self.populate_search_paths(); + } + let mut matches = vec![]; + let mut matcher = SkimMatcherV2::default().use_cache(true).element_limit(100); // TODO: no hard + // coded limit! + self.search_file_names(&search_term, &mut matcher, &mut matches); + self.search_file_contents(&search_term, &mut matcher, &mut matches); + + // if the search term changed before we finished, let's search again! + if let Ok(current_search_term) = std::fs::read(CURRENT_SEARCH_TERM) { + let current_search_term = String::from_utf8_lossy(¤t_search_term); // TODO: not lossy, search can be lots of stuff + if current_search_term != search_term { + return self.search(current_search_term.into()); + } + } + (search_term, matches) + } + fn populate_search_paths(&mut self) { + // TODO: CONTINUE HERE - when we start, check to see if /data/search_data exists, if it is + // deserialize it and place it in our own state, if not, do the below and then write to it + eprintln!("populate_search_paths"); + if let Ok(search_data) = std::fs::read("/data/search_data") { // TODO: add cwd to here + eprintln!("found search data"); + if let Ok(mut existing_state) = serde_json::from_str::<Self>(&String::from_utf8_lossy(&search_data)) { + eprintln!("successfully deserialized search data"); + std::mem::swap(self, &mut existing_state); + return; + } + } + for entry in WalkDir::new(ROOT).into_iter().filter_map(|e| e.ok()) { + if self.skip_hidden_files && entry.file_name().to_str().map(|s| s.starts_with('.')).unwrap_or(false) { + continue; + } + let file_path = entry.path().display().to_string(); + + if entry.metadata().unwrap().is_file() { + if let Ok(file) = std::fs::File::open(&file_path) { + let lines = io::BufReader::new(file).lines(); + for (index, line) in lines.enumerate() { + match line { + Ok(line) => { + self.search_file_contents.push((file_path.clone(), index + 1, line)); + }, + Err(_) => { + break; // probably a binary file, skip it + } + } + } + } + } + + self.search_paths.push(file_path); + } + let serialized_state = serde_json::to_string(&self).unwrap(); // TODO: unwrap city + std::fs::write("/data/search_data", serialized_state.as_bytes()).unwrap(); + if let Ok(search_data) = std::fs::read("/data/search_data") { + if let Ok(mut existing_state) = serde_json::from_str::<Self>(&String::from_utf8_lossy(&search_data)) { + std::mem::swap(self, &mut existing_state); + return; + } + } + } + fn search_file_names(&self, search_term: &str, matcher: &mut SkimMatcherV2, matches: &mut Vec<SearchResult>) { + for entry in &self.search_paths { + if let Some((score, indices)) = matcher.fuzzy_indices(&entry, &search_term) { + matches.push(SearchResult::new_file_name(score, indices, entry.to_owned())); + } + } + } + fn search_file_contents(&self, search_term: &str, matcher: &mut SkimMatcherV2, matches: &mut Vec<SearchResult>) { + for (file_name, line_number, line_entry) in &self.search_file_contents { + if let Some((score, indices)) = matcher.fuzzy_indices(&line_entry, &search_term) { + matches.push(SearchResult::new_file_line(score, indices, file_name.clone(), line_entry.clone(), *line_number)); + } + } + } +} + +impl State { + pub fn render_search(&mut self, rows: usize, cols: usize) { + if let Some(search_term) = self.search_term.as_ref() { + let mut to_render = String::new(); + to_render.push_str(&format!("\u{1b}[38;5;51;1mSEARCH:\u{1b}[m {}\n", search_term)); + let mut rows_left_to_render = rows.saturating_sub(3); + if self.loading && self.search_results.is_empty() { + to_render.push_str(&self.render_loading()); + } + for (i, result) in self.search_results.iter().enumerate().take(rows.saturating_sub(3)) { + let result_height = result.rendered_height(); + if result_height > rows_left_to_render { + break; + } + rows_left_to_render -= result_height; + let is_selected = i == self.selected_search_result; + let rendered_result = result.render(cols, is_selected); + to_render.push_str(&format!("\n{}", rendered_result)); + } + print!("{}", to_render); + } + } + pub fn render_loading(&self) -> String { + let mut rendered = String::from("Scanning folder"); + let dot_count = self.loading_animation_offset % 4; + for _ in 0..dot_count { + rendered.push('.'); + } + rendered + } +} |