summaryrefslogtreecommitdiffstats
path: root/default-plugins/strider/src/search.rs
diff options
context:
space:
mode:
Diffstat (limited to 'default-plugins/strider/src/search.rs')
-rw-r--r--default-plugins/strider/src/search.rs415
1 files changed, 415 insertions, 0 deletions
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(&current_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
+ }
+}