diff options
author | Aram Drevekenin <aram@poor.dev> | 2023-05-16 12:47:18 +0200 |
---|---|---|
committer | GitHub <noreply@github.com> | 2023-05-16 12:47:18 +0200 |
commit | 5fe4d60c220c872c1e77a7ddf24fec6686c28e95 (patch) | |
tree | b6abec3006de7cd2eaecee149005d55ee4671a6b /default-plugins | |
parent | 4b7d7c34b72e67168a2ec53b27776047ccddf522 (diff) |
feat(plugins): Plugin workers and strider (#2449)
* mvp of strider fuzzy find
* improve search ui
* various refactoringz
* moar refactoring
* even more refactoring
* tests and more refactoring
* refactor: remove unused stuff
* style(fmt): rustfmt
* debug ci
* debug ci
* correct path for plugin system tests
* fix plugin system ci tests
* remove debugging statements from test
* fix plugin worker persistence
* add test for plugin worker persistence
* style(fmt): rustfmt
* final cleanups
* remove outdated comments
Diffstat (limited to 'default-plugins')
-rw-r--r-- | default-plugins/fixture-plugin-for-tests/.cargo/config.toml | 2 | ||||
-rw-r--r-- | default-plugins/fixture-plugin-for-tests/Cargo.toml | 11 | ||||
l--------- | default-plugins/fixture-plugin-for-tests/LICENSE.md | 1 | ||||
-rw-r--r-- | default-plugins/fixture-plugin-for-tests/src/main.rs | 78 | ||||
-rw-r--r-- | default-plugins/strider/Cargo.toml | 6 | ||||
-rw-r--r-- | default-plugins/strider/src/main.rs | 113 | ||||
-rw-r--r-- | default-plugins/strider/src/search.rs | 415 | ||||
-rw-r--r-- | default-plugins/strider/src/state.rs | 88 |
8 files changed, 702 insertions, 12 deletions
diff --git a/default-plugins/fixture-plugin-for-tests/.cargo/config.toml b/default-plugins/fixture-plugin-for-tests/.cargo/config.toml new file mode 100644 index 000000000..bc255e30b --- /dev/null +++ b/default-plugins/fixture-plugin-for-tests/.cargo/config.toml @@ -0,0 +1,2 @@ +[build] +target = "wasm32-wasi"
\ No newline at end of file diff --git a/default-plugins/fixture-plugin-for-tests/Cargo.toml b/default-plugins/fixture-plugin-for-tests/Cargo.toml new file mode 100644 index 000000000..39211c340 --- /dev/null +++ b/default-plugins/fixture-plugin-for-tests/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "fixture-plugin-for-tests" +version = "0.1.0" +authors = ["Aram Drevekenin <aram@poor.dev>"] +edition = "2021" +license = "MIT" + +[dependencies] +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +zellij-tile = { path = "../../zellij-tile" } diff --git a/default-plugins/fixture-plugin-for-tests/LICENSE.md b/default-plugins/fixture-plugin-for-tests/LICENSE.md new file mode 120000 index 000000000..f0608a63a --- /dev/null +++ b/default-plugins/fixture-plugin-for-tests/LICENSE.md @@ -0,0 +1 @@ +../../LICENSE.md
\ No newline at end of file diff --git a/default-plugins/fixture-plugin-for-tests/src/main.rs b/default-plugins/fixture-plugin-for-tests/src/main.rs new file mode 100644 index 000000000..124b2dc75 --- /dev/null +++ b/default-plugins/fixture-plugin-for-tests/src/main.rs @@ -0,0 +1,78 @@ +use serde::{Deserialize, Serialize}; +use zellij_tile::prelude::*; + +// This is a fixture plugin used only for tests in Zellij +// it is not (and should not!) be included in the mainline executable +// it's included here for convenience so that it will be built by the CI + +#[derive(Default)] +struct State { + received_events: Vec<Event>, + received_payload: Option<String>, +} + +#[derive(Default, Serialize, Deserialize)] +struct TestWorker { + number_of_messages_received: usize, +} + +impl<'de> ZellijWorker<'de> for TestWorker { + fn on_message(&mut self, message: String, payload: String) { + if message == "ping" { + self.number_of_messages_received += 1; + post_message_to_plugin( + "pong".into(), + format!( + "{}, received {} messages", + payload, self.number_of_messages_received + ), + ); + } + } +} + +register_plugin!(State); +register_worker!(TestWorker, test_worker); + +impl ZellijPlugin for State { + fn load(&mut self) { + subscribe(&[ + EventType::InputReceived, + EventType::SystemClipboardFailure, + EventType::CustomMessage, + ]); + } + + fn update(&mut self, event: Event) -> bool { + match &event { + Event::CustomMessage(message, payload) => { + if message == "pong" { + self.received_payload = Some(payload.clone()); + } + }, + Event::SystemClipboardFailure => { + // this is just to trigger the worker message + post_message_to( + "test", + "ping".to_owned(), + "gimme_back_my_payload".to_owned(), + ); + }, + _ => {}, + } + let should_render = true; + self.received_events.push(event); + should_render + } + + fn render(&mut self, rows: usize, cols: usize) { + if let Some(payload) = self.received_payload.as_ref() { + println!("Payload from worker: {:?}", payload); + } else { + println!( + "Rows: {:?}, Cols: {:?}, Received events: {:?}", + rows, cols, self.received_events + ); + } + } +} diff --git a/default-plugins/strider/Cargo.toml b/default-plugins/strider/Cargo.toml index f7801bfaf..d45a8ff21 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 = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +unicode-width = "0.1.8" +ansi_term = "0.12.1" diff --git a/default-plugins/strider/src/main.rs b/default-plugins/strider/src/main.rs index 6868c6706..4f299c508 100644 --- a/default-plugins/strider/src/main.rs +++ b/default-plugins/strider/src/main.rs @@ -1,16 +1,28 @@ +mod search; mod state; use colored::*; -use state::{refresh_directory, FsEntry, State}; +use search::{ResultsOfSearch, SearchWorker}; +use serde_json; +use state::{refresh_directory, FsEntry, State, CURRENT_SEARCH_TERM}; use std::{cmp::min, time::Instant}; use zellij_tile::prelude::*; register_plugin!(State); +register_worker!(SearchWorker, search_worker); 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, + ]); + 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 +34,101 @@ impl ZellijPlugin for State { }; self.ev_history.push_back((event.clone(), Instant::now())); match event { + Event::Timer(_elapsed) => { + 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" => { + 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 +198,10 @@ 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..299882eac --- /dev/null +++ b/default-plugins/strider/src/search.rs @@ -0,0 +1,415 @@ +use crate::state::{State, CURRENT_SEARCH_TERM, ROOT}; + +use unicode_width::UnicodeWidthStr; +use zellij_tile::prelude::*; + +use fuzzy_matcher::skim::SkimMatcherV2; +use fuzzy_matcher::FuzzyMatcher; +use serde::{Deserialize, Serialize}; +use walkdir::WalkDir; + +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 { + let green_code = 154; + let orange_code = 166; + let bold_code = "\u{1b}[1m"; + let green_foreground = format!("\u{1b}[38;5;{}m", green_code); + let orange_foreground = format!("\u{1b}[38;5;{}m", orange_code); + let reset_code = "\u{1b}[m"; + let max_width = max_width.saturating_sub(3); // for the UI left line separator + match self { + SearchResult::File { path, indices, .. } => { + if is_selected { + let line = self.render_line_with_indices( + path, + indices, + max_width, + None, + Some(green_code), + true, + ); + format!("{} | {}{}", green_foreground, reset_code, line) + } else { + let line = + self.render_line_with_indices(path, indices, max_width, None, None, true); + format!(" | {}", line) + } + }, + SearchResult::LineInFile { + path, + line, + line_number, + indices, + .. + } => { + if is_selected { + let first_line = self.render_line_with_indices( + path, + &vec![], + max_width, + None, + Some(green_code), + true, + ); + let line_indication_text = format!("{}-> {}", bold_code, line_number); + let line_indication = format!( + "{}{}{}", + orange_foreground, line_indication_text, reset_code + ); // TODO: also truncate + let second_line = self.render_line_with_indices( + line, + indices, + max_width.saturating_sub(line_indication_text.width()), + None, + Some(orange_code), + false, + ); + format!( + " {}│{} {}\n {}│{} {} {}", + green_foreground, + reset_code, + first_line, + green_foreground, + reset_code, + line_indication, + second_line + ) + } else { + let first_line = + self.render_line_with_indices(path, &vec![], max_width, None, None, true); // TODO: + let line_indication_text = format!("{}-> {}", bold_code, line_number); + let second_line = self.render_line_with_indices( + line, + indices, + max_width.saturating_sub(line_indication_text.width()), + None, + None, + false, + ); + format!( + " │ {}\n │ {} {}", + first_line, line_indication_text, second_line + ) + } + }, + } + } + fn render_line_with_indices( + &self, + line_to_render: &String, + indices: &Vec<usize>, + max_width: usize, + background_color: Option<usize>, + foreground_color: Option<usize>, + is_bold: bool, + ) -> String { + // TODO: get these from Zellij + let reset_code = "\u{1b}[m"; + let underline_code = "\u{1b}[4m"; + let foreground_color = foreground_color + .map(|c| format!("\u{1b}[38;5;{}m", c)) + .unwrap_or_else(|| format!("")); + let background_color = background_color + .map(|c| format!("\u{1b}[48;5;{}m", c)) + .unwrap_or_else(|| format!("")); + let bold = if is_bold { "\u{1b}[1m" } else { "" }; + let non_index_character_style = format!("{}{}{}", background_color, foreground_color, bold); + let index_character_style = format!( + "{}{}{}{}", + background_color, foreground_color, bold, underline_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); + let mut second_half = format!("{}", reset_code); + 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(&index_character_style); + first_half.push(character); + first_half.push_str(reset_code); + } else { + first_half.push_str(&non_index_character_style); + first_half.push(character); + first_half.push_str(reset_code); + } + } else if Some(i) > truncate_end_position { + if indices.contains(&i) { + second_half.push_str(&index_character_style); + second_half.push(character); + second_half.push_str(reset_code); + } else { + second_half.push_str(&non_index_character_style); + second_half.push(character); + second_half.push_str(reset_code); + } + } + } + if let Some(_truncate_start_position) = truncate_start_position { + format!( + "{}{}{}[..]{}{}{}", + first_half, reset_code, foreground_color, reset_code, 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<'de> ZellijWorker<'de> for SearchWorker { + // TODO: handle out of order messages, likely when rendering + fn on_message(&mut self, message: String, payload: String) { + match message.as_str() { + // TODO: deserialize to type + "scan_folder" => { + self.populate_search_paths(); + 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); + }, + }, + _ => {}, + } + } +} + +impl SearchWorker { + 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) { + 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); + } + } + 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(), < |