diff options
| author | Aram Drevekenin <aram@poor.dev> | 2023-06-07 12:43:35 +0200 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2023-06-07 12:43:35 +0200 |
| commit | c11d75f9157873fc99fe0d40933de8ec5fbb4f6b (patch) | |
| tree | fc55dc7f9132395585613dd9cce94c98c8c76600 /default-plugins | |
| parent | docs(changelog): fix cwd of newtab action (diff) | |
| download | sl0ppij-c11d75f9157873fc99fe0d40933de8ec5fbb4f6b.tar.gz sl0ppij-c11d75f9157873fc99fe0d40933de8ec5fbb4f6b.tar.xz sl0ppij-c11d75f9157873fc99fe0d40933de8ec5fbb4f6b.zip | |
feat(wasm-plugin-system): major overhaul and some goodies (#2510)
* strider resiliency
* worker channel prototype
* finalized ui
* show hide plugin
* fs events to plugins
* tests for events and new screen instructions
* various refactoringz
* report plugin errors instead of crashing zellij
* fix plugin loading with workers
* refactor: move watch filesystem
* some fixes and refactoring
* refactor(panes): combine pane insertion logic
* refactor(screen): launch or focus
* refactor(pty): consolidate default shell fetching
* refactor: various cleanups
* initial refactoring
* more initial refactoring
* refactor(strider): search
* style(fmt): rustfmt
* style(pty): cleanup
* style(clippy): ok clippy
* style(fmt): rustfmt
Diffstat (limited to 'default-plugins')
| -rw-r--r-- | default-plugins/fixture-plugin-for-tests/src/main.rs | 6 | ||||
| -rw-r--r-- | default-plugins/strider/Cargo.toml | 1 | ||||
| -rw-r--r-- | default-plugins/strider/src/main.rs | 191 | ||||
| -rw-r--r-- | default-plugins/strider/src/search.rs | 415 | ||||
| -rw-r--r-- | default-plugins/strider/src/search/controls_line.rs | 353 | ||||
| -rw-r--r-- | default-plugins/strider/src/search/mod.rs | 329 | ||||
| -rw-r--r-- | default-plugins/strider/src/search/search_results.rs | 308 | ||||
| -rw-r--r-- | default-plugins/strider/src/search/search_state.rs | 241 | ||||
| -rw-r--r-- | default-plugins/strider/src/search/selection_controls_area.rs | 61 | ||||
| -rw-r--r-- | default-plugins/strider/src/search/ui.rs | 120 | ||||
| -rw-r--r-- | default-plugins/strider/src/state.rs | 80 |
11 files changed, 1548 insertions, 557 deletions
diff --git a/default-plugins/fixture-plugin-for-tests/src/main.rs b/default-plugins/fixture-plugin-for-tests/src/main.rs index 124b2dc7..7e4139f5 100644 --- a/default-plugins/fixture-plugin-for-tests/src/main.rs +++ b/default-plugins/fixture-plugin-for-tests/src/main.rs @@ -32,7 +32,7 @@ impl<'de> ZellijWorker<'de> for TestWorker { } register_plugin!(State); -register_worker!(TestWorker, test_worker); +register_worker!(TestWorker, test_worker, TEST_WORKER); impl ZellijPlugin for State { fn load(&mut self) { @@ -40,6 +40,10 @@ impl ZellijPlugin for State { EventType::InputReceived, EventType::SystemClipboardFailure, EventType::CustomMessage, + EventType::FileSystemCreate, + EventType::FileSystemRead, + EventType::FileSystemUpdate, + EventType::FileSystemDelete, ]); } diff --git a/default-plugins/strider/Cargo.toml b/default-plugins/strider/Cargo.toml index d45a8ff2..65b5d23e 100644 --- a/default-plugins/strider/Cargo.toml +++ b/default-plugins/strider/Cargo.toml @@ -16,3 +16,4 @@ serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" unicode-width = "0.1.8" ansi_term = "0.12.1" +strip-ansi-escapes = "0.1.1" diff --git a/default-plugins/strider/src/main.rs b/default-plugins/strider/src/main.rs index 4f299c50..966b53a4 100644 --- a/default-plugins/strider/src/main.rs +++ b/default-plugins/strider/src/main.rs @@ -2,26 +2,45 @@ mod search; mod state; use colored::*; -use search::{ResultsOfSearch, SearchWorker}; +use search::{FileContentsWorker, FileNameWorker, MessageToSearch, ResultsOfSearch}; +use serde::{Deserialize, Serialize}; use serde_json; -use state::{refresh_directory, FsEntry, State, CURRENT_SEARCH_TERM}; +use state::{refresh_directory, FsEntry, State}; use std::{cmp::min, time::Instant}; use zellij_tile::prelude::*; register_plugin!(State); -register_worker!(SearchWorker, search_worker); +register_worker!(FileNameWorker, file_name_search_worker, FILE_NAME_WORKER); +register_worker!( + FileContentsWorker, + file_contents_search_worker, + FILE_CONTENTS_WORKER +); impl ZellijPlugin for State { fn load(&mut self) { refresh_directory(self); - self.loading = true; + self.search_state.loading = true; subscribe(&[ EventType::Key, EventType::Mouse, EventType::CustomMessage, EventType::Timer, + EventType::FileSystemCreate, + EventType::FileSystemUpdate, + EventType::FileSystemDelete, ]); - post_message_to("search", String::from("scan_folder"), String::new()); + post_message_to( + "file_name_search", + serde_json::to_string(&MessageToSearch::ScanFolder).unwrap(), + "".to_owned(), + ); + post_message_to( + "file_contents_search", + serde_json::to_string(&MessageToSearch::ScanFolder).unwrap(), + "".to_owned(), + ); + self.search_state.loading = true; set_timeout(0.5); // for displaying loading animation } @@ -35,57 +54,43 @@ impl ZellijPlugin for State { self.ev_history.push_back((event.clone(), Instant::now())); match event { Event::Timer(_elapsed) => { - should_render = true; - if self.loading { + if self.search_state.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); - } + self.search_state.progress_animation(); + should_render = true; } }, - Event::CustomMessage(message, payload) => match message.as_str() { - "update_search_results" => { - if let Ok(mut results_of_search) = - serde_json::from_str::<ResultsOfSearch>(&payload) + Event::CustomMessage(message, payload) => match serde_json::from_str(&message) { + Ok(MessageToPlugin::UpdateFileNameSearchResults) => { + if let Ok(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; - } + self.search_state + .update_file_name_search_results(results_of_search); + should_render = true; + } + }, + Ok(MessageToPlugin::UpdateFileContentsSearchResults) => { + if let Ok(results_of_search) = serde_json::from_str::<ResultsOfSearch>(&payload) + { + self.search_state + .update_file_contents_search_results(results_of_search); + should_render = true; } }, - "done_scanning_folder" => { - self.loading = false; + Ok(MessageToPlugin::DoneScanningFolder) => { + self.search_state.loading = false; should_render = true; }, - _ => {}, + Err(e) => eprintln!("Failed to deserialize custom message: {:?}", e), }, 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()), - ); - } + Key::Esc if self.typing_search_term() => { + self.stop_typing_search_term(); + self.search_state.handle_key(key); should_render = true; }, - Key::Esc if self.exploring_search_results() => { - self.stop_exploring_search_results(); + _ if self.typing_search_term() => { + self.search_state.handle_key(key); should_render = true; }, Key::Char('/') => { @@ -94,40 +99,27 @@ impl ZellijPlugin for State { }, Key::Esc => { self.stop_typing_search_term(); + hide_self(); should_render = true; }, Key::Up | Key::Char('k') => { - if self.exploring_search_results() { - self.move_search_selection_up(); + let currently_selected = self.selected(); + *self.selected_mut() = self.selected().saturating_sub(1); + if currently_selected != self.selected() { 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') => { - if self.exploring_search_results() { - self.move_search_selection_down(); + 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; - } 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(); - } + self.traverse_dir_or_open_file(); + self.ev_history.clear(); should_render = true; }, Key::Left | Key::Char('h') => { @@ -190,6 +182,54 @@ impl ZellijPlugin for State { }, _ => {}, }, + Event::FileSystemCreate(paths) => { + let paths: Vec<String> = paths + .iter() + .map(|p| p.to_string_lossy().to_string()) + .collect(); + post_message_to( + "file_name_search", + serde_json::to_string(&MessageToSearch::FileSystemCreate).unwrap(), + serde_json::to_string(&paths).unwrap(), + ); + post_message_to( + "file_contents_search", + serde_json::to_string(&MessageToSearch::FileSystemCreate).unwrap(), + serde_json::to_string(&paths).unwrap(), + ); + }, + Event::FileSystemUpdate(paths) => { + let paths: Vec<String> = paths + .iter() + .map(|p| p.to_string_lossy().to_string()) + .collect(); + post_message_to( + "file_name_search", + serde_json::to_string(&MessageToSearch::FileSystemUpdate).unwrap(), + serde_json::to_string(&paths).unwrap(), + ); + post_message_to( + "file_contents_search", + serde_json::to_string(&MessageToSearch::FileSystemUpdate).unwrap(), + serde_json::to_string(&paths).unwrap(), + ); + }, + Event::FileSystemDelete(paths) => { + let paths: Vec<String> = paths + .iter() + .map(|p| p.to_string_lossy().to_string()) + .collect(); + post_message_to( + "file_name_search", + serde_json::to_string(&MessageToSearch::FileSystemDelete).unwrap(), + serde_json::to_string(&paths).unwrap(), + ); + post_message_to( + "file_contents_search", + serde_json::to_string(&MessageToSearch::FileSystemDelete).unwrap(), + serde_json::to_string(&paths).unwrap(), + ); + }, _ => { dbg!("Unknown event {:?}", event); }, @@ -198,8 +238,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); + if self.typing_search_term() { + self.search_state.change_size(rows, cols); + print!("{}", self.search_state); + return; } for i in 0..rows { @@ -221,9 +263,9 @@ impl ZellijPlugin for State { if i == self.selected() { if is_last_row { - print!("{}", path.reversed()); + print!("{}", path.clone().reversed()); } else { - println!("{}", path.reversed()); + println!("{}", path.clone().reversed()); } } else { if is_last_row { @@ -238,3 +280,10 @@ impl ZellijPlugin for State { } } } + +#[derive(Serialize, Deserialize)] +pub enum MessageToPlugin { + UpdateFileNameSearchResults, + UpdateFileContentsSearchResults, + DoneScanningFolder, +} diff --git a/default-plugins/strider/src/search.rs b/default-plugins/strider/src/search.rs deleted file mode 100644 index 299882ea..00000000 --- a/default-plugins/strider/src/search.rs +++ /dev/null @@ -1,415 +0,0 @@ -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(), - *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 + 1 > rows_left_to_render { - break; - } - rows_left_to_render -= result_height; - rows_left_to_render -= 1; // space between - let is_selected = i == self.selected_search_result; - let rendered_result = result.render(cols, is_selected); - to_render.push_str(&format!("\n{}\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 - } -} diff --git a/default-plugins/strider/src/search/controls_line.rs b/default-plugins/strider/src/search/controls_line.rs new file mode 100644 index 00000000..a75715ef --- /dev/null +++ b/default-plugins/strider/src/search/controls_line.rs @@ -0,0 +1,353 @@ +use crate::search::search_state::SearchType; +use crate::search::ui::{ + arrow, bold, color_line_to_end, dot, styled_text, BLACK, GRAY_DARK, GRAY_LIGHT, RED, WHITE, +}; + +#[derive(Default)] +pub struct ControlsLine { + controls: Vec<Control>, + scanning_indication: Option<Vec<&'static str>>, + animation_offset: u8, +} + +impl ControlsLine { + pub fn new(controls: Vec<Control>, scanning_indication: Option<Vec<&'static str>>) -> Self { + ControlsLine { + controls, + scanning_indication, + ..Default::default() + } + } + pub fn with_animation_offset(mut self, animation_offset: u8) -> Self { + self.animation_offset = animation_offset; + self + } + pub fn render(&self, max_width: usize, show_controls: bool) -> String { + if show_controls { + self.render_controls(max_width) + } else { + self.render_empty_line(max_width) + } + } + pub fn render_controls(&self, max_width: usize) -> String { + let loading_animation = + LoadingAnimation::new(&self.scanning_indication, self.animation_offset); + let full_length = loading_animation.full_len() + + self.controls.iter().map(|c| c.full_len()).sum::<usize>(); + let mid_length = + loading_animation.mid_len() + self.controls.iter().map(|c| c.mid_len()).sum::<usize>(); + let short_length = loading_animation.short_len() + + self.controls.iter().map(|c| c.short_len()).sum::<usize>(); + if max_width >= full_length { + let mut to_render = String::new(); + for control in &self.controls { + to_render.push_str(&control.render_full_length()); + } + to_render.push_str(&self.render_padding(max_width.saturating_sub(full_length))); + to_render.push_str(&loading_animation.render_full_length()); + to_render + } else if max_width >= mid_length { + let mut to_render = String::new(); + for control in &self.controls { + to_render.push_str(&control.render_mid_length()); + } + to_render.push_str(&self.render_padding(max_width.saturating_sub(mid_length))); + to_render.push_str(&loading_animation.render_mid_length()); + to_render + } else if max_width >= short_length { + let mut to_render = String::new(); + for control in &self.controls { + to_render.push_str(&control.render_short_length()); + } + to_render.push_str(&self.render_padding(max_width.saturating_sub(short_length))); + to_render.push_str(&loading_animation.render_short_length()); + to_render + } else { + format!("") + } + } + pub fn render_empty_line(&self, max_width: usize) -> String { + let loading_animation = + LoadingAnimation::new(&self.scanning_indication, self.animation_offset); + let mut to_render = String::new(); + if max_width >= loading_animation.full_len() { + to_render.push_str( + &self.render_padding(max_width.saturating_sub(loading_animation.full_len())), + ); + to_render.push_str(&loading_animation.render_full_length()); + } else if max_width >= loading_animation.mid_len() { + to_render.push_str( + &self.render_padding(max_width.saturating_sub(loading_animation.mid_len())), + ); + to_render.push_str(&loading_animation.render_mid_length()); + } else if max_width >= loading_animation.short_len() { + to_render.push_str( + &self.render_padding(max_width.saturating_sub(loading_animation.short_len())), + ); + to_render.push_str(&loading_animation.render_short_length()); + } + to_render + } + fn render_padding(&self, padding: usize) -> String { + // TODO: color whole line + format!("{}\u{1b}[{}C", color_line_to_end(GRAY_LIGHT), padding) + } +} + +pub struct Control { + key: &'static str, + options: Vec<&'static str>, + option_index: (usize, usize), // eg. 1 out of 2 (1, 2) + keycode_background_color: u8, + keycode_foreground_color: u8, + control_text_background_color: u8, + control_text_foreground_color: u8, + active_dot_color: u8, +} + +impl Default for Control { + fn default() -> Self { + Control { + key: "", + options: vec![], + option_index: (0, 0), + keycode_background_color: GRAY_LIGHT, + keycode_foreground_color: WHITE, + control_text_background_color: GRAY_DARK, + control_text_foreground_color: BLACK, + active_dot_color: RED, + } + } +} + +impl Control { + pub fn new( + key: &'static str, + options: Vec<&'static str>, + option_index: (usize, usize), + ) -> Self { + Control { + key, + options, + option_index, + ..Default::default() + } + } + pub fn new_floating_control(key: &'static str, should_open_floating: bool) -> Self { + if should_open_floating { + Control::new(key, vec!["OPEN FLOATING", "FLOATING", "F"], (2, 2)) + } else { + Control::new(key, vec!["OPEN TILED", "TILED", "T"], (1, 2)) + } + } + pub fn new_filter_control(key: &'static str, search_filter: &SearchType) -> Self { + match search_filter { + SearchType::NamesAndContents => Control::new( + key, + vec!["FILE NAMES AND CONTENTS", "NAMES + CONTENTS", "N+C"], + (1, 3), + ), + SearchType::Names => Control::new(key, vec!["FILE NAMES", "NAMES", "N"], (2, 3)), + SearchType::Contents => { + Control::new(key, vec!["FILE CONTENTS", "CONTENTS", "C"], (3, 3)) + }, + } + } + pub fn short_len(&self) -> usize { + let short_text = self + .options + .get(2) + .or_else(|| self.options.get(1)) + .or_else(|| self.options.get(0)) + .unwrap_or(&""); + short_text.chars().count() + self.key.chars().count() + self.option_index.1 + 7 + // 7 for all the spaces and decorations + } + pub fn mid_len(&self) -> usize { + let mid_text = self + .options + .get(1) + .or_else(|| self.options.get(0)) + .unwrap_or(&""); + mid_text.chars().count() + self.key.chars().count() + self.option_index.1 + 7 + // 7 for all the spaces and decorations + } + pub fn full_len(&self) -> usize { + let full_text = self.options.get(0).unwrap_or(&""); + full_text.chars().count() + self.key.chars().count() + self.option_index.1 + 7 + // 7 for all the spaces and decorations + } + pub fn render_short_length(&self) -> String { + let short_text = self + .options + .get(2) + .or_else(|| self.options.get(1)) + .or_else(|| self.options.get(0)) + .unwrap_or(&""); + self.render(short_text) + } + pub fn render_mid_length(&self) -> String { + let mid_text = self + .options + .get(1) + .or_else(|| self.options.get(0)) + .unwrap_or(&""); + self.render(mid_text) + } + pub fn render_full_length(&self) -> String { + let full_text = self.options.get(0).unwrap_or(&""); + self.render(full_text) + } + fn render(&self, text: &str) -> String { + format!( + "{}{}{}{}{}{}", + self.render_keycode(&format!(" {} ", self.key)), + arrow( + self.keycode_background_color, + self.control_text_background_color + ), + self.render_selection_dots(), + self.render_control_text(&format!("{} ", text)), + arrow( + self.control_text_background_color, + self.keycode_background_color + ), + color_line_to_end(self.keycode_background_color), + ) + } + fn render_keycode(&self, text: &str) -> String { + styled_text( + self.keycode_foreground_color, + self.keycode_background_color, + &bold(text), + ) + } + fn render_control_text(&self, text: &str) -> String { + styled_text( + self.control_text_foreground_color, + self.control_text_background_color, + &bold(text), + ) + } + fn render_selection_dots(&self) -> String { + let mut selection_dots = String::from(" "); + for i in 1..=self.option_index.1 { + if i == self.option_index.0 { + selection_dots.push_str(&dot( + self.active_dot_color, + self.control_text_background_color, + )); + } else { + selection_dots.push_str(&dot( + self.control_text_foreground_color, + self.control_text_background_color, + )); + } + } + selection_dots.push_str(" "); + selection_dots + } +} + +struct LoadingAnimation { + scanning_indication: Option<Vec<&'static str>>, + animation_offset: u8, + background_color: u8, + foreground_color: u8, +} +impl LoadingAnimation { + pub fn new(scanning_indication: &Option<Vec<&'static str>>, animation_offset: u8) -> Self { + LoadingAnimation { + scanning_indication: scanning_indication.clone(), + animation_offset, + background_color: GRAY_LIGHT, + foreground_color: WHITE, + } + } + pub fn full_len(&self) -> usize { + self.scanning_indication + .as_ref() + .and_then(|scanning_indication| scanning_indication.get(0)) + .map(|s| s.chars().count() + 3) // 3 for animation dots + .unwrap_or(0) + } + pub fn mid_len(&self) -> usize { + self.scanning_indication + .as_ref() + .and_then(|scanning_indication| { + scanning_indication + .get(1) + .or_else(|| scanning_indication.get(0)) + }) + .map(|s| s.chars().count() + 3) // 3 for animation dots + .unwrap_or(0) + } + pub fn short_len(&self) -> usize { + self.scanning_indication + .as_ref() + .and_then(|scanning_indication| { + scanning_indication + .get(2) + .or_else(|| scanning_indication.get(1)) + .or_else(|| scanning_indication.get(0)) + }) + .map(|s| s.chars().count() + 3) // 3 for animation dots + .unwrap_or(0) + } + pub fn render_full_length(&self) -> String { + self.scanning_indication + .as_ref() + .and_then(|scanning_indication| scanning_indication.get(0)) + .map(|s| { + styled_text( + self.foreground_color, + self.background_color, + &bold(&(s.to_string() + &self.animation_dots())), + ) + }) + .unwrap_or_else(String::new) + } + pub fn render_mid_length(&self) -> String { + self.scanning_indication + .as_ref() + .and_then(|scanning_indication| { + scanning_indication + .get(1) + .or_else(|| scanning_indication.get(0)) + }) + .map(|s| { + styled_text( + self.background_color, + self.foreground_color, + &bold(&(s.to_string() + &self.animation_dots())), + ) + }) + .unwrap_or_else(String::new) + } + pub fn render_short_length(&self) -> String { + self.scanning_indication + .as_ref() + .and_then(|scanning_indication| { + scanning_indication + .get(2) + .or_else(|| scanning_indication.get(1)) + .or_else(|| scanning_indication.get(0)) + }) + .map(|s| { + styled_text( + self.background_color, + self.foreground_color, + &bold(&(s.to_string() + &self.animation_dots())), + ) + }) + .unwrap_or_else(String::new) + } + fn animation_dots(&self) -> String { + let mut to_render = String::from(""); + let dot_count = self.animation_offset % 4; + for _ in 0..dot_count { + to_render.push('.'); + } + to_render + } +} diff --git a/default-plugins/strider/src/search/mod.rs b/default-plugins/strider/src/search/mod.rs new file mode 100644 index 00000000..5aba9c4d --- /dev/null +++ b/default-plugins/strider/src/search/mod.rs @@ -0,0 +1,329 @@ +pub mod controls_line; +pub mod search_results; +pub mod search_state; +pub mod selection_controls_area; +pub mod ui; + +use crate::state::{CURRENT_SEARCH_TERM, ROOT}; +use crate::MessageToPlugin; +use search_state::SearchType; +use std::collections::{BTreeMap, BTreeSet, HashMap}; +use std::path::{Path, PathBuf}; + +use unicode_width::UnicodeWidthStr; +use zellij_tile::prelude::*; + +use fuzzy_matcher::skim::SkimMatcherV2; +use fuzzy_matcher::FuzzyMatcher; +use search_results::SearchResult; +use serde::{Deserialize, Serialize}; +use walkdir::WalkDir; + +use std::io::{self, BufRead}; + +#[derive(Default, Serialize, Deserialize)] +pub struct Search { + search_type: SearchType, + file_names: BTreeSet<String>, + file_contents: BTreeMap<(String, usize), String>, // file_name, line_number, line + cached_file_name_results: HashMap<String, Vec<SearchResult>>, + cached_file_contents_results: HashMap<String, Vec<SearchResult>>, +} + +impl Search { + pub fn new(search_type: SearchType) -> Self { + Search { + search_type, + ..Default::default() + } + } + fn on_message(&mut self, message: String, payload: String) { + match serde_json::from_str::<MessageToSearch>(&message) { + Ok(MessageToSearch::ScanFolder) => { + self.scan_hd(); + post_message_to_plugin( + serde_json::to_string(&MessageToPlugin::DoneScanningFolder).unwrap(), + "".to_owned(), + ); + }, + Ok(MessageToSearch::Search) => { + if let Some(current_search_term) = self.read_search_term_from_hd_cache() { + self.search(current_search_term); + } + }, + Ok(MessageToSearch::FileSystemCreate) => { + self.rescan_files(payload); + }, + Ok(MessageToSearch::FileSystemUpdate) => { + self.rescan_files(payload); + }, + Ok(MessageToSearch::FileSystemDelete) => { + self.delete_files(payload); + }, + Err(e) => eprintln!("Failed to deserialize worker message {:?}", e), + } + } + pub fn scan_hd(&mut self) { + for entry in WalkDir::new(ROOT).into_iter().filter_map(|e| e.ok()) { + self.add_file_entry(entry.path(), entry.metadata().ok()); + } + } + pub fn search(&mut self, search_term: String) { + let search_results_limit = 100; // artificial limit to prevent probably unwanted chaos + // let mut matcher = SkimMatcherV2::default().use_cache(true).element_limit(search_results_limit); + let mut file_names_search_results = None; + let mut file_contents_search_results = None; + if let SearchType::Names | SearchType::NamesAndContents = self.search_type { + let file_names_matches = match self.cached_file_name_results.get(&search_term) { + Some(cached_results) => cached_results.clone(), + None => { + let mut matcher = SkimMatcherV2::default().use_cache(true); + let results = self.search_file_names(&search_term, &mut matcher); + self.cached_file_name_results + .insert(search_term.clone(), results.clone()); + results + }, + }; + file_names_search_results = Some( + ResultsOfSearch::new(search_term.clone(), file_names_matches) + .limit_search_results(search_results_limit), + ); + }; + if let SearchType::Contents | SearchType::NamesAndContents = self.search_type { + let file_contents_matches = match self.cached_file_contents_results.get(&search_term) { + Some(cached_results) => cached_results.clone(), + None => { + let mut matcher = SkimMatcherV2::default().use_cache(true); + let results = self.search_file_contents(&search_term, &mut matcher); + self.cached_file_contents_results + .insert(search_term.clone(), results.clone()); + results + }, + }; + file_contents_search_results = Some( + ResultsOfSearch::new(search_term.clone(), file_contents_matches) + .limit_search_results(search_results_limit), + ); + }; + + // if the search term changed before we finished, let's search again! + if let Some(current_search_term) = self.read_search_term_from_hd_cache() { + if current_search_term != search_term { + return self.search(current_search_term.into()); + } + } + if let Some(file_names_search_results) = file_names_search_results { + post_message_to_plugin( + serde_json::to_string(&MessageToPlugin::UpdateFileNameSearchResults).unwrap(), + serde_json::to_string(&file_names_search_results).unwrap(), + ); + } + if let Some(file_contents_search_results) = file_contents_search_results { + post_message_to_plugin( + serde_json::to_string(&MessageToPlugin::UpdateFileContentsSearchResults).unwrap(), + serde_json::to_string(&file_contents_search_results).unwrap(), + ); + } + } + pub fn rescan_files(&mut self, paths: String) { + match serde_json::from_str::<Vec<PathBuf>>(&paths) { + Ok(paths) => { + for path in paths { + self.add_file_entry(&path, path.metadata().ok()); + } + self.cached_file_name_results.clear(); + self.cached_file_contents_results.clear(); + }, + Err(e) => eprintln!("Failed to deserialize paths: {:?}", e), + } + } + pub fn delete_files(&mut self, paths: String) { + match serde_json::from_str::<Vec<PathBuf>>(&paths) { + Ok(paths) => { + self.remove_existing_entries(&paths); + self.cached_file_name_results.clear(); + self.cached_file_contents_results.clear(); + }, + Err(e) => eprintln!("Failed to deserialize paths: {:?}", e), + } + } + fn add_file_entry(&mut self, file_name: &Path, file_metadata: Option<std::fs::Metadata>) { + let file_path = file_name.display().to_string(); + let file_path_stripped_prefix = self.strip_file_prefix(&file_name); + + self.file_names.insert(file_path_stripped_prefix.clone()); + if let SearchType::NamesAndContents | SearchType::Contents = self.search_type { + if file_metadata.map(|f| f.is_file()).unwrap_or(false) { + 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.file_contents.insert( + ( + // String::from_utf8_lossy(&strip_ansi_escapes::strip(file_path_stripped_prefix.clone()).unwrap()).to_string(), + file_path_stripped_prefix.clone(), + index + 1, + ), + String::from_utf8_lossy( + &strip_ansi_escapes::strip(line).unwrap(), + ) + .to_string(), + ); + }, + Err(_) => { + break; // probably a binary file, skip it + }, + } + } + } + } + } + } + fn search_file_names( + &self, + search_term: &str, + matcher: &mut SkimMatcherV2, + ) -> Vec<SearchResult> { + let mut matches = vec![]; + for entry in &self.file_names { + if let Some((score, indices)) = matcher.fuzzy_indices(&entry, &search_term) { + matches.push(SearchResult::new_file_name( + score, + indices, + entry.to_owned(), + )); + } + } + matches + } + fn search_file_contents( + &self, + search_term: &str, + matcher: &mut SkimMatcherV2, + ) -> Vec<SearchResult> { + let mut matches = vec![]; + for ((file_name, line_number), line_entry) in &self.file_contents { + if line_entry.contains("struct") { + if line_entry.len() < 400 { + eprintln!("matching against: {:?}", line_entry) + } else { + eprintln!("matching again line that has struct but is very long") + } + } + if let Some((score, indices)) = matcher.fuzzy_indices(&line_entry, &search_term) { + if line_entry.contains("struct") { + eprintln!("score: {:?}", score) + } + matches.push(SearchResult::new_file_line( + score, + indices, + file_name.clone(), + line_entry.clone(), + *line_number, + )); + } else { + if line_entry.contains("struct") { + eprintln!("no score!") + } + } + } + matches + } + fn strip_file_prefix(&self, file_name: &Path) -> String { + let mut file_path_stripped_prefix = file_name.display().to_string().split_off(ROOT.width()); + if file_path_stripped_prefix.starts_with('/') { + file_path_stripped_prefix.remove(0); + } + file_path_stripped_prefix + } + fn read_search_term_from_hd_cache(&self) -> Option<String> { + match std::fs::read(CURRENT_SEARCH_TERM) { + Ok(current_search_term) => { + Some(String::from_utf8_lossy(¤t_search_term).to_string()) + }, + _ => None, + } + } + fn remove_existing_entries(&mut self, paths: &Vec<PathBuf>) { + let file_path_stripped_prefixes: Vec<String> = + paths.iter().map(|p| self.strip_file_prefix(&p)).collect(); + self.file_names + .retain(|file_name| !file_path_stripped_prefixes.contains(file_name)); + self.file_contents.retain(|(file_name, _line_in_file), _| { + !file_path_stripped_prefixes.contains(file_name) + }); + } +} + +#[derive(Serialize, Deserialize)] +pub enum MessageToSearch { + ScanFolder, + Search, + FileSystemCreate, + FileSystemUpdate, + FileSystemDelete, +} + +#[derive(Serialize, Deserialize)] +pub struct FileNameWorker { + search: Search, +} + +impl Default for FileNameWorker { + fn default() -> Self { + FileNameWorker { + search: Search::new(SearchType::Names), + } + } +} + +#[derive(Serialize, Deserialize)] +pub struct FileContentsWorker { + search: Search, +} + +impl Default for FileContentsWorker { + fn default() -> Self { + FileContentsWorker { + search: Search::new(SearchType::Contents), + } + } +} + +impl<'de> ZellijWorker<'de> for FileNameWorker { + fn on_message(&mut self, message: String, payload: String) { + self.search.on_message(message, payload); + } +} + +impl<'de> ZellijWorker<'de> for FileContentsWorker { + fn on_message(&mut self, message: String, payload: String) { + self.search.on_message(message, payload); + } +} + +#[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 + } +} diff --git a/default-plugins/strider/src/search/search_results.rs b/default-plugins/strider/src/search/search_results.rs new file mode 100644 index 00000000..b76200ae --- /dev/null +++ b/default-plugins/strider/src/search/search_results.rs @@ -0,0 +1,308 @@ +use crate::search::ui::{ + bold, styled_text, styled_text_background, styled_text_foreground, underline, GRAY_LIGHT, + GREEN, ORANGE, +}; +use serde::{Deserialize, Serialize}; +use unicode_width::UnicodeWidthStr; + +#[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 is_same_entry(&self, other: &Self) -> bool { + match (&self, other) { + ( + SearchResult::File { path: my_path, .. }, + SearchResult::File { + path: other_path, .. + }, + ) => my_path == other_path, + ( + SearchResult::LineInFile { + path: my_path, + line_number: my_line_number, + .. + }, + SearchResult::LineInFile { + path: other_path, + line_number: other_line_number, + .. + }, + ) => my_path == other_path && my_line_number == other_line_number, + _ => false, + } + } + pub fn render( + &self, + max_width: usize, + is_selected: bool, + is_below_search_result: bool, + ) -> String { + let max_width = max_width.saturating_sub(4); // for the UI left line separator + match self { + SearchResult::File { path, indices, .. } => self.render_file_result( + path, + indices, + is_selected, + is_below_search_result, + max_width, + ), + SearchResult::LineInFile { + path, + line, + line_number, + indices, + .. + } => self.render_line_in_file_result( + path, + line, + *line_number, + indices, + is_selected, + is_below_search_result, + max_width, + ), + } + } + fn render_file_result( + &self, + path: &String, + indices: &Vec<usize>, + is_selected: bool, + is_below_search_result: bool, + max_width: usize, + ) -> String { + if is_selected { + let line = self.render_line_with_indices( + path, + indices, + max_width.saturating_sub(3), + Some(GREEN), + ); + let selection_arrow = styled_text_foreground(ORANGE, "┌>"); + format!("{} {}", selection_arrow, line) + } else { + let line_prefix = if is_below_search_result { "│ " } else { " " }; + let line = + self.render_line_with_indices(path, indices, max_width.saturating_sub(3), None); + format!("{} {}", line_prefix, line) + } + } + fn render_line_in_file_result( + &self, + path: &String, + line: &String, + line_number: usize, + indices: &Vec<usize>, + is_selected: bool, + is_below_search_result: bool, + max_width: usize, + ) -> String { + let line_number_prefix_text = format!("└ {} ", line_number); + let max_width_of_line_in_file = max_width + .saturating_sub(3) + .saturating_sub(line_number_prefix_text.width()); + if is_selected { + let file_name_line = self.render_line_with_indices( + path, + &vec![], + max_width.saturating_sub(3), + Some(GREEN), + ); + let line_in_file = self.render_line_with_indices( + line, + indices, + max_width_of_line_in_file, + Some(GREEN), + ); + let line_number_prefix = styled_text_foreground(GREEN, &bold(&line_number_prefix_text)); + format!( + "{} {}\n│ {}{}", + styled_text_foreground(ORANGE, "┌>"), + file_name_line, + line_number_prefix, + line_in_file + ) + } else { + let file_name_line = + self.render_line_with_indices(path, &vec![], max_width.saturating_sub(3), None); + let line_in_file = + self.render_line_with_indices(line, indices, max_width_of_line_in_file, None); + let line_number_prefix = bold(&line_number_prefix_text); + let line_prefix = if is_below_search_result { "│ " } else { " " }; + format!( + "{} {}\n{} {}{}", + line_prefix, file_name_line, line_prefix, line_number_prefix, line_in_file + ) + } + } + fn render_line_with_indices( + &self, + line_to_render: &String, + indices: &Vec<usize>, + max_width: usize, + foreground_color: Option<u8>, + ) -> String { + let non_index_character_style = |c: &str| match foreground_color { + Some(foreground_color) => styled_text_foreground(foreground_color, &bold(c)), + None => bold(c), + }; + let index_character_style = |c: &str| match foreground_color { + Some(foreground_color) => { + styled_text(foreground_color, GRAY_LIGHT, &bold(&underline(c))) + }, + None => styled_text_background(GRAY_LIGHT, &bold(&underline(c))), + }; + + let truncate_positions = + self.truncate_line_with_indices(line_to_render, indices, max_width); + let truncate_start_position = truncate_positions.map(|p| p.0).unwrap_or(0); + let truncate_end_position = truncate_positions + .map(|p| p.1) + .unwrap_or(line_to_render.chars().count()); + let mut visible_portion = String::new(); + for (i, character) in line_to_render.chars().enumerate() { + if i >= truncate_start_position && i <= truncate_end_position { + if indices.contains(&i) { + visible_portion.push_str(&index_character_style(&character.to_string())); + } else { + visible_portion.push_str(&non_index_character_style(&character.to_string())); + } + } + } + if truncate_positions.is_some() { + let left_truncate_sign = if truncate_start_position == 0 { + "" + } else { + ".." + }; + let right_truncate_sign = if truncate_end_position == line_to_render.chars().count() { + "" + } else { + ".." + }; + format!( + "{}{}{}", + non_index_character_style(left_truncate_sign), + visible_portion, + non_index_character_style(right_truncate_sign) + ) + } else { + visible_portion + } + } + fn truncate_line_with_indices( + &self, + line_to_render: &String, + indices: &Vec<usize>, + max_width: usize, + ) -> Option<(usize, usize)> { + let first_index = indices.get(0).copied().unwrap_or(0); + let last_index = indices + .last() + .copied() + .unwrap_or_else(|| std::cmp::min(line_to_render.chars().count(), max_width)); + if line_to_render.width() <= max_width { + // there's enough room, no need to truncate + None + } else if last_index.saturating_sub(first_index) < max_width { + // truncate around the indices + let mut width_remaining = max_width + .saturating_sub(1) + .saturating_sub(last_index.saturating_sub(first_index)); + + let mut string_start_position = first_index; + let mut string_end_position = last_index; + + let mut i = 0; + loop { + if i >= width_remaining { + break; + } + if string_start_position > 0 && string_end_position < line_to_render.chars().count() + { + let take_from_start = i % 2 == 0; + if take_from_start { + string_start_position -= 1; + if string_start_position == 0 { + width_remaining += 2; // no need for truncating dots + } + } else { + string_end_position += 1; + if string_end_position == line_to_render.chars().count() { + width_remaining += 2; // no need for truncating dots + } + } + } else if string_end_position < line_to_render.chars().count() { + string_end_position += 1; + if string_end_position == line_to_render.chars().count() { + width_remaining += 2; // no need for truncating dots + } + } else if string_start_position > 0 { + string_start_position -= 1; + if string_start_position == 0 { + width_remaining += 2; // no need for truncating dots + } + } else { + break; + } + i += 1; + } + Some((string_start_position, string_end_position)) + } else if !indices.is_empty() { + // no room for all indices, remove the last one and try again + let mut new_indices = indices.clone(); + new_indices.pop(); + self.truncate_line_with_indices(line_to_render, &new_indices, max_width) + } else { + Some((first_index, last_index)) + } + } +} diff --git a/default-plugins/strider/src/search/search_state.rs b/default-plugins/strider/src/search/search_state.rs new file mode 100644 index 00000000..5766d1c2 --- /dev/null +++ b/default-plugins/strider/src/search/search_state.rs @@ -0,0 +1,241 @@ +use crate::search::search_results::SearchResult; +use crate::search::{MessageToSearch, ResultsOfSearch}; +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; +use zellij_tile::prelude::{ + hide_self, open_file, open_file_floating, open_file_with_line, open_file_with_line_floating, + open_terminal, open_terminal_floating, post_message_to, Key, +}; + +pub const CURRENT_SEARCH_TERM: &str = "/data/current_search_term"; + +#[derive(Default)] +pub struct SearchState { + pub search_term: String, + pub file_name_search_results: Vec<SearchResult>, + pub file_contents_search_results: Vec<SearchResult>, + pub loading: bool, + pub loading_animation_offset: u8, + pub selected_search_result: usize, + pub should_open_floating: bool, + pub search_filter: SearchType, + pub display_rows: usize, + pub display_columns: usize, + pub displayed_search_results: (usize, Vec<SearchResult>), // usize is selected index +} + +impl SearchState { + pub fn handle_key(&mut self, key: Key) { + match key { + Key::Down => self.move_search_selection_down(), + Key::Up => self.move_search_selection_up(), + Key::Char('\n') => self.open_search_result_in_editor(), + Key::BackTab => self.open_search_result_in_terminal(), + Key::Ctrl('f') => { + self.should_open_floating = !self.should_open_floating; + }, + Key::Ctrl('r') => self.toggle_search_filter(), + Key::Esc => { + hide_self(); + self.clear_state(); + }, + _ => self.append_to_search_term(key), + } + } + pub fn update_file_name_search_results(&mut self, mut results_of_search: ResultsOfSearch) { + if self.search_term == results_of_search.search_term { + self.file_name_search_results = results_of_search.search_results.drain(..).collect(); + self.update_displayed_search_results(); + } + } + pub fn update_file_contents_search_results(&mut self, mut results_of_search: ResultsOfSearch) { + if self.search_term == results_of_search.search_term { + self.file_contents_search_results = + results_of_search.search_results.drain(..).collect(); + self.update_displayed_search_results(); + } + } + pub fn change_size(&mut self, rows: usize, cols: usize) { + self.display_rows = rows; + self.display_columns = cols; + } + pub fn progress_animation(&mut self) { + if self.loading_animation_offset == u8::MAX { + self.loading_animation_offset = 0; + } else { + self.loading_animation_offset = self.loading_animation_offset.saturating_add(1); + } + } + pub fn number_of_lines_in_displayed_search_results(&self) -> usize { + self.displayed_search_results + .1 + .iter() + .map(|l| l.rendered_height()) + .sum() + } + fn move_search_selection_down(&mut self) { + if self.displayed_search_results.0 < self.max_search_selection_index() { + self.displayed_search_results.0 += 1; + } + } + fn move_search_selection_up(&mut self) { + self.displayed_search_results.0 = self.displayed_search_results.0.saturating_sub(1); + } + fn open_search_result_in_editor(&mut self) { + match self.selected_search_result_entry() { + Some(SearchResult::File { path, .. }) => { + if self.should_open_floating { + open_file_floating(&PathBuf::from(path)) + } else { + open_file(&PathBuf::from(path)); + } + }, + Some(SearchResult::LineInFile { + path, line_number, .. + }) => { + if self.should_open_floating { + open_file_with_line_floating(&PathBuf::from(path), line_number); + } else { + open_file_with_line(&PathBuf::from(path), line_number); + } + }, + None => eprintln!("Search results not found"), + } + } + fn open_search_result_in_terminal(&mut self) { + let dir_path_of_result = |path: &str| -> PathBuf { + let file_path = PathBuf::from(path); + let mut dir_path = file_path.components(); + dir_path.next_back(); // remove file name to stay with just the folder + dir_path.as_path().into() + }; + let selected_search_result_entry = self.selected_search_result_entry(); + if let Some(SearchResult::File { path, .. }) | Some(SearchResult::LineInFile { path, .. }) = + selected_search_result_entry + { + let dir_path = dir_path_of_result(&path); + if self.should_open_floating { + open_terminal_floating(&dir_path); + } else { + open_terminal(&dir_path); + } + } + } + fn toggle_search_filter(&mut self) { + self.search_filter.progress(); + self.send_search_query(); + } + fn clear_state(&mut self) { + self.file_name_search_results.clear(); + self.file_contents_search_results.clear(); + self.displayed_search_results = (0, vec![]); + self.search_term.clear(); + } + fn append_to_search_term(&mut self, key: Key) { + match key { + Key::Char(character) => { + self.search_term.push(character); + }, + Key::Backspace => { + self.search_term.pop(); + if self.search_term.len() == 0 { + self.clear_state(); + } + }, + _ => {}, + } + self.send_search_query(); + } + fn send_search_query(&mut self) { + match std::fs::write(CURRENT_SEARCH_TERM, &self.search_term) { + Ok(_) => { + if !self.search_term.is_empty() { + post_message_to( + "file_name_search", + serde_json::to_string(&MessageToSearch::Search).unwrap(), + "".to_owned(), + ); + post_message_to( + "file_contents_search", + serde_json::to_string(&MessageToSearch::Search).unwrap(), + "".to_owned(), + ); + self.file_name_search_results.clear(); + self.file_contents_search_results.clear(); + } + }, + Err(e) => eprintln!("Failed to write search term to HD, aborting search: {}", e), + } + } + fn max_search_selection_index(&self) -> usize { + self.displayed_search_results.1.len().saturating_sub(1) + } + fn update_displayed_search_results(&mut self) { + if self.search_term.is_empty() { + self.clear_state(); + return; + } + let mut search_results_of_interest = match self.search_filter { + SearchType::NamesAndContents => { + let mut all_search_results = self.file_name_search_results.clone(); + all_search_results.append(&mut self.file_contents_search_results.clone()); + all_search_results.sort_by(|a, b| b.score().cmp(&a.score())); + all_search_results + }, + SearchType::Names => self.file_name_search_results.clone(), + SearchType::Contents => self.file_contents_search_results.clone(), + }; + let mut height_taken_up_by_results = 0; + let mut displayed_search_results = vec![]; + for search_result in search_results_of_interest.drain(..) { + if height_taken_up_by_results + search_result.rendered_height() + > self.rows_for_results() + { + break; + } + height_taken_up_by_results += search_result.rendered_height(); + displayed_search_results.push(search_result); + } + let new_index = self + .selected_search_result_entry() + .and_then(|currently_selected_search_result| { + displayed_search_results + .iter() + .position(|r| r.is_same_entry(¤tly_selected_search_result)) + }) + .unwrap_or(0); + self.displayed_search_results = (new_index, displayed_search_results); + } + fn selected_search_result_entry(&self) -> Option<SearchResult> { + self.displayed_search_results + .1 + .get(self.displayed_search_results.0) + .cloned() + } + pub fn rows_for_results(&self) -> usize { + self.display_rows.saturating_sub(3) // search line and 2 controls lines + } +} + +#[derive(Serialize, Deserialize)] +pub enum SearchType { + NamesAndContents, + Names, + Contents, +} + +impl SearchType { + pub fn progress(&mut self) { + match &self { + &SearchType::NamesAndContents => *self = SearchType::Names, + &SearchType::Names => *self = SearchType::Contents, + &SearchType::Contents => *self = SearchType::NamesAndContents, + } + } +} + +impl Default for SearchType { + fn default() -> Self { + SearchType::NamesAndContents + } +} diff --git a/default-plugins/strider/src/search/selection_controls_area.rs b/default-plugins/strider/src/search/selection_controls_area.rs new file mode 100644 index 00000000..0805e1e4 --- /dev/null +++ b/default-plugins/strider/src/search/selection_controls_area.rs @@ -0,0 +1,61 @@ +use crate::search::ui::{bold, styled_text_foreground, ORANGE}; + +pub struct SelectionControlsArea { + display_lines: usize, + display_columns: usize, +} + +impl SelectionControlsArea { + pub fn new(display_lines: usize, display_columns: usize) -> Self { + SelectionControlsArea { + display_lines, + display_columns, + } + } + pub fn render(&self, result_count: usize) -> String { + let mut to_render = String::new(); + let padding = self.display_lines.saturating_sub(result_count); + for _ in 0..padding { + to_render.push_str(&self.render_padding_line()); + } + let selection_controls = self.render_selection_controls(); + to_render.push_str(&selection_controls); + to_render + } + pub fn render_empty_lines(&self) -> String { + let mut to_render = String::new(); + for _ in 0..self.display_lines { + to_render.push_str("\n"); + } + to_render + } + fn render_padding_line(&self) -> String { + format!("│\n") + } + fn render_selection_controls(&self) -> String { + if self.display_columns >= self.full_selection_controls_len() { + self.render_full_selection_controls() + } else { + self.render_truncated_selection_controls() + } + } + fn full_selection_controls_len(&self) -> usize { + 62 + } + fn render_full_selection_controls(&self) -> String { + let arrow_tail = "└ "; + let enter = styled_text_foreground(ORANGE, &bold("<ENTER>")); + let enter_tip = bold(" - open in editor. "); + let tab = styled_text_foreground(ORANGE, &bold("<TAB>")); + let tab_tip = bold(" - open terminal at location."); + format!("{}{}{}{}{}", arrow_tail, enter, enter_tip, tab, tab_tip) + } + fn render_truncated_selection_controls(&self) -> String { + let arrow_tail = "└ "; + let enter = styled_text_foreground(ORANGE, &bold("<ENTER>")); + let enter_tip = bold(" - edit. "); + let tab = styled_text_foreground(ORANGE, &bold("<TAB>")); + let tab_tip = bold(" - terminal."); + format!("{}{}{}{}{}", arrow_tail, enter, enter_tip, tab, tab_tip) + } +} diff --git a/default-plugins/strider/src/search/ui.rs b/default-plugins/strider/src/search/ui.rs new file mode 100644 index 00000000..5ee5a3e8 --- /dev/null +++ b/default-plugins/strider/src/search/ui.rs @@ -0,0 +1,120 @@ +use crate::search::controls_line::{Control, ControlsLine}; +use crate::search::search_state::SearchState; +use crate::search::selection_controls_area::SelectionControlsArea; +use std::fmt::{Display, Formatter, Result}; + +pub const CYAN: u8 = 51; +pub const GRAY_LIGHT: u8 = 238; +pub const GRAY_DARK: u8 = 245; +pub const WHITE: u8 = 15; +pub const BLACK: u8 = 16; +pub const RED: u8 = 124; +pub const GREEN: u8 = 154; +pub const ORANGE: u8 = 166; + +impl Display for SearchState { + fn fmt(&self, f: &mut Formatter) -> Result { + write!(f, "{}", self.render_search_line())?; + write!(f, "{}", self.render_search_results())?; + write!(f, "{}", self.render_selection_control_area())?; + write!(f, "{}", self.render_controls_line())?; + Ok(()) + } +} + +impl SearchState { + pub fn render_search_line(&self) -> String { + format!( + "{}{}\n", + styled_text_foreground(CYAN, &bold("SEARCH: ")), + self.search_term + ) + } + pub fn render_search_results(&self) -> String { + let mut space_for_results = self.display_rows.saturating_sub(3); // title and both controls lines + let mut to_render = String::new(); + for (i, search_result) in self.displayed_search_results.1.iter().enumerate() { + let result_height = search_result.rendered_height(); + if space_for_results < result_height { + break; + } + space_for_results -= result_height; + let index_of_selected_result = self.displayed_search_results.0; + let is_selected = i == index_of_selected_result; + let is_below_search_result = i > index_of_selected_result; + let rendered_result = + search_result.render(self.display_columns, is_selected, is_below_search_result); + to_render.push_str(&format!("{}", rendered_result)); + to_render.push('\n') + } + to_render + } + pub fn render_selection_control_area(&self) -> String { + let rows_for_results = self.rows_for_results(); + if !self.displayed_search_results.1.is_empty() { + format!( + "{}\n", + SelectionControlsArea::new(rows_for_results, self.display_columns) + .render(self.number_of_lines_in_displayed_search_results()) + ) + } else { + format!( + "{}\n", + SelectionControlsArea::new(rows_for_results, self.display_columns) + .render_empty_lines() + ) + } + } + pub fn render_controls_line(&self) -> String { + let has_results = !self.displayed_search_results.1.is_empty(); + let tiled_floating_control = + Control::new_floating_control("Ctrl f", self.should_open_floating); + let names_contents_control = Control::new_filter_control("Ctrl r", &self.search_filter); + if self.loading { + ControlsLine::new( + vec![tiled_floating_control, names_contents_control], + Some(vec!["Scanning folder", "Scanning", "S"]), + ) + .with_animation_offset(self.loading_animation_offset) + .render(self.display_columns, has_results) + } else { + ControlsLine::new(vec![tiled_floating_control, names_contents_control], None) + .render(self.display_columns, has_results) + } + } +} + +pub fn bold(text: &str) -> String { + format!("\u{1b}[1m{}\u{1b}[m", text) +} + +pub fn underline(text: &str) -> String { + format!("\u{1b}[4m{}\u{1b}[m", text) +} + +pub fn styled_text(foreground_color: u8, background_color: u8, text: &str) -> String { + format!( + "\u{1b}[38;5;{};48;5;{}m{}\u{1b}[m", + foreground_color, background_color, text + ) +} + +pub fn styled_text_foreground(foreground_color: u8, text: &str) -> String { + format!("\u{1b}[38;5;{}m{}\u{1b}[m", foreground_color, text) +} + +pub fn styled_text_background(background_color: u8, text: &str) -> String { + format!("\u{1b}[48;5;{}m{}\u{1b}[m", background_color, text) +} + +pub fn color_line_to_end(background_color: u8) -> String { + format!("\u{1b}[48;5;{}m\u{1b}[0K", background_color) +} + +pub fn arrow(foreground: u8, background: u8) -> String { + format!("\u{1b}[38;5;{}m\u{1b}[48;5;{}m", foreground, background) +} + +pub fn dot(foreground: u8, background: u8) -> String { + format!("\u{1b}[38;5;{};48;5;{}m•", foreground, background) +} diff --git a/default-plugins/strider/src/state.rs b/default-plugins/strider/src/state.rs index 7ced4932..8dd09653 100644 --- a/default-plugins/strider/src/state.rs +++ b/default-plugins/strider/src/state.rs @@ -1,4 +1,6 @@ -use crate::search::SearchResult; +use crate::search::search_results::SearchResult; +use crate::search::search_state::SearchState; +use crate::search::search_state::SearchType; use pretty_bytes::converter as pb; use std::{ collections::{HashMap, VecDeque}, @@ -18,65 +20,29 @@ pub struct State { pub cursor_hist: HashMap<PathBuf, (usize, usize)>, pub hide_hidden_files: bool, pub ev_history: VecDeque<(Event, Instant)>, // stores last event, can be expanded in future + pub search_state: SearchState, pub search_paths: Vec<String>, pub search_term: Option<String>, - pub search_results: Vec<SearchResult>, + pub file_name_search_results: Vec<SearchResult>, + pub file_contents_search_results: Vec<SearchResult>, pub loading: bool, pub loading_animation_offset: u8, pub typing_search_term: bool, - pub exploring_search_results: bool, pub selected_search_result: usize, + pub processed_search_index: usize, + pub should_open_floating: bool, + pub search_filter: SearchType, } impl State { - pub fn append_to_search_term(&mut self, key: Key) { - match key { - Key::Char(character) => { - if let Some(search_term) = self.search_term.as_mut() { - search_term.push(character); - } - }, - Key::Backspace => { - if let Some(search_term) = self.search_term.as_mut() { - search_term.pop(); - if search_term.len() == 0 { - self.search_term = None; - self.typing_search_term = false; - } - } - }, - _ => {}, - } - } - pub fn accept_search_term(&mut self) { - self.typing_search_term = false; - self.exploring_search_results = true; - } pub fn typing_search_term(&self) -> bool { self.typing_search_term } - pub fn exploring_search_results(&self) -> bool { - self.exploring_search_results - } - pub fn stop_exploring_search_results(&mut self) { - self.exploring_search_results = false; - } pub fn start_typing_search_term(&mut self) { - if self.search_term.is_none() { - self.search_term = Some(String::new()); - } self.typing_search_term = true; } pub fn stop_typing_search_term(&mut self) { - self.typing_search_term = true; - } - pub fn move_search_selection_up(&mut self) { - self.selected_search_result = self.selected_search_result.saturating_sub(1); - } - pub fn move_search_selection_down(&mut self) { - if self.selected_search_result < self.search_results.len() { - self.selected_search_result = self.selected_search_result.saturating_add(1); - } + self.typing_search_term = false; } pub fn selected_mut(&mut self) -> &mut usize { &mut self.cursor_hist.entry(self.path.clone()).or_default().0 @@ -104,32 +70,6 @@ impl State { } } } - pub fn open_search_result(&mut self) { - match self.search_results.get(self.selected_search_result) { - Some(SearchResult::File { - path, - score, - indices, - }) => { - let file_path = PathBuf::from(path); - open_file(file_path.strip_prefix(ROOT).unwrap()); - }, - Some(SearchResult::LineInFile { - path, - score, - indices, - line, - line_number, - }) => { - let file_path = PathBuf::from(path); - open_file_with_line(file_path.strip_prefix(ROOT).unwrap(), *line_number); - // open_file_with_line(&file_path, *line_number); // TODO: no!! - }, - None => { - eprintln!("Search result not found"); - }, - } - } } #[derive(PartialEq, Eq, PartialOrd, Ord, Clone)] |
