From 9dacf06fb0fc9eb7ce23d583788a1c203a7389f1 Mon Sep 17 00:00:00 2001 From: oxcrow Date: Sat, 8 Mar 2025 02:14:53 +0530 Subject: [PATCH 01/10] feat: Add local search in buffer Grep search through a local buffer similar to `global_search`. The method works but it should be improved by someone more experienced. --- helix-term/src/commands.rs | 235 +++++++++++++++++++++++++++++++ helix-term/src/keymap/default.rs | 1 + 2 files changed, 236 insertions(+) diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs index a197792ef..33f1556f0 100644 --- a/helix-term/src/commands.rs +++ b/helix-term/src/commands.rs @@ -379,6 +379,7 @@ impl MappableCommand { search_selection_detect_word_boundaries, "Use current selection as the search pattern, automatically wrapping with `\\b` on word boundaries", make_search_word_bounded, "Modify current search to make it word bounded", global_search, "Global search in workspace folder", + local_search, "Local search in buffer", extend_line, "Select current line, if already selected, extend to another line based on the anchor", extend_line_below, "Select current line, if already selected, extend to next line", extend_line_above, "Select current line, if already selected, extend to previous line", @@ -2646,6 +2647,240 @@ fn global_search(cx: &mut Context) { cx.push_layer(Box::new(overlaid(picker))); } +/// Local search in buffer +fn local_search(cx: &mut Context) { + #[derive(Debug)] + struct FileResult { + path: PathBuf, + line_num: usize, + } + + impl FileResult { + fn new(path: &Path, line_num: usize) -> Self { + Self { + path: path.to_path_buf(), + line_num, + } + } + } + + struct LocalSearchConfig { + smart_case: bool, + file_picker_config: helix_view::editor::FilePickerConfig, + directory_style: Style, + number_style: Style, + colon_style: Style, + } + + let editor_config = cx.editor.config(); + let config = LocalSearchConfig { + smart_case: editor_config.search.smart_case, + file_picker_config: editor_config.file_picker.clone(), + directory_style: cx.editor.theme.get("ui.text.directory"), + number_style: cx.editor.theme.get("constant.numeric.integer"), + colon_style: cx.editor.theme.get("punctuation"), + }; + + let columns = [ + PickerColumn::new("path", |item: &FileResult, config: &LocalSearchConfig| { + let path = helix_stdx::path::get_relative_path(&item.path); + + let directories = path + .parent() + .filter(|p| !p.as_os_str().is_empty()) + .map(|p| format!("{}{}", p.display(), std::path::MAIN_SEPARATOR)) + .unwrap_or_default(); + + let filename = item + .path + .file_name() + .expect("local search paths are normalized (can't end in `..`)") + .to_string_lossy(); + + Cell::from(Spans::from(vec![ + Span::styled(directories, config.directory_style), + Span::raw(filename), + Span::styled(":", config.colon_style), + Span::styled((item.line_num + 1).to_string(), config.number_style), + ])) + }), + PickerColumn::hidden("contents"), + ]; + + let get_files = |query: &str, + editor: &mut Editor, + config: std::sync::Arc, + injector: &ui::picker::Injector<_, _>| { + if query.is_empty() { + return async { Ok(()) }.boxed(); + } + + let search_root = helix_stdx::env::current_working_dir(); + if !search_root.exists() { + return async { Err(anyhow::anyhow!("Current working directory does not exist")) } + .boxed(); + } + + let documents: Vec<_> = editor + .documents() + .map(|doc| (doc.path().cloned(), doc.text().to_owned())) + .collect(); + + let matcher = match RegexMatcherBuilder::new() + .case_smart(config.smart_case) + .build(query) + { + Ok(matcher) => { + // Clear any "Failed to compile regex" errors out of the statusline. + editor.clear_status(); + matcher + } + Err(err) => { + log::info!("Failed to compile search pattern in global search: {}", err); + return async { Err(anyhow::anyhow!("Failed to compile regex")) }.boxed(); + } + }; + + let dedup_symlinks = config.file_picker_config.deduplicate_links; + let absolute_root = search_root + .canonicalize() + .unwrap_or_else(|_| search_root.clone()); + + let injector = injector.clone(); + async move { + let searcher = SearcherBuilder::new() + .binary_detection(BinaryDetection::quit(b'\x00')) + .build(); + WalkBuilder::new(search_root) + .hidden(config.file_picker_config.hidden) + .parents(config.file_picker_config.parents) + .ignore(config.file_picker_config.ignore) + .follow_links(config.file_picker_config.follow_symlinks) + .git_ignore(config.file_picker_config.git_ignore) + .git_global(config.file_picker_config.git_global) + .git_exclude(config.file_picker_config.git_exclude) + .max_depth(config.file_picker_config.max_depth) + .filter_entry(move |entry| { + filter_picker_entry(entry, &absolute_root, dedup_symlinks) + }) + .add_custom_ignore_filename(helix_loader::config_dir().join("ignore")) + .add_custom_ignore_filename(".helix/ignore") + .build_parallel() + .run(|| { + let mut searcher = searcher.clone(); + let matcher = matcher.clone(); + let injector = injector.clone(); + let documents = &documents; + Box::new(move |entry: Result| -> WalkState { + let entry = match entry { + Ok(entry) => entry, + Err(_) => return WalkState::Continue, + }; + + match entry.file_type() { + Some(entry) if entry.is_file() => {} + // skip everything else + _ => return WalkState::Continue, + }; + + let mut stop = false; + let sink = sinks::UTF8(|line_num, _line_content| { + stop = injector + .push(FileResult::new(entry.path(), line_num as usize - 1)) + .is_err(); + + Ok(!stop) + }); + let doc = documents.iter().find(|&(doc_path, _)| { + doc_path + .as_ref() + .is_some_and(|doc_path| doc_path == entry.path()) + }); + + // search in current document + let result = if let Some((_, doc)) = doc { + // there is already a buffer for this file + // search the buffer instead of the file because it's faster + // and captures new edits without requiring a save + if searcher.multi_line_with_matcher(&matcher) { + // in this case a continuous buffer is required + // convert the rope to a string + let text = doc.to_string(); + searcher.search_slice(&matcher, text.as_bytes(), sink) + } else { + searcher.search_reader( + &matcher, + RopeReader::new(doc.slice(..)), + sink, + ) + } + } else { + // Note: This is a hack! + // We ignore all other files. + // We only search an empty string (to satisfy rust's return type). + searcher.search_slice(&matcher, "".to_owned().as_bytes(), sink) + }; + + if let Err(err) = result { + log::error!("Local search error: {}, {}", entry.path().display(), err); + } + if stop { + WalkState::Quit + } else { + WalkState::Continue + } + }) + }); + Ok(()) + } + .boxed() + }; + + let reg = cx.register.unwrap_or('/'); + cx.editor.registers.last_search_register = reg; + + let picker = Picker::new( + columns, + 1, // contents + [], + config, + move |cx, FileResult { path, line_num, .. }, action| { + let doc = match cx.editor.open(path, action) { + Ok(id) => doc_mut!(cx.editor, &id), + Err(e) => { + cx.editor + .set_error(format!("Failed to open file '{}': {}", path.display(), e)); + return; + } + }; + + let line_num = *line_num; + let view = view_mut!(cx.editor); + let text = doc.text(); + if line_num >= text.len_lines() { + cx.editor.set_error( + "The line you jumped to does not exist anymore because the file has changed.", + ); + return; + } + let start = text.line_to_char(line_num); + let end = text.line_to_char((line_num + 1).min(text.len_lines())); + + doc.set_selection(view.id, Selection::single(start, end)); + if action.align_view(view, doc.id()) { + align_view(doc, view, Align::Center); + } + }, + ) + .with_preview(|_editor, FileResult { path, line_num, .. }| { + Some((path.as_path().into(), Some((*line_num, *line_num)))) + }) + .with_history_register(Some(reg)) + .with_dynamic_query(get_files, Some(275)); + + cx.push_layer(Box::new(overlaid(picker))); +} + enum Extend { Above, Below, diff --git a/helix-term/src/keymap/default.rs b/helix-term/src/keymap/default.rs index e160b2246..1352ecd0c 100644 --- a/helix-term/src/keymap/default.rs +++ b/helix-term/src/keymap/default.rs @@ -282,6 +282,7 @@ pub fn default() -> HashMap { "P" => paste_clipboard_before, "R" => replace_selections_with_clipboard, "/" => global_search, + "l" => local_search, "k" => hover, "r" => rename_symbol, "h" => select_references_to_symbol_under_cursor, From 68d7b5cda12f5d6a5aeec26c959b2e4cc6ff797b Mon Sep 17 00:00:00 2001 From: oxcrow Date: Sun, 9 Mar 2025 21:04:50 +0530 Subject: [PATCH 02/10] feat: hide directory and filename path for local_search As per review comments of helix maintainers, - The filename and directory path was hidden for local_search - The : colon separator was also hidden since it is not required --- helix-term/src/commands.rs | 31 +++++++------------------------ 1 file changed, 7 insertions(+), 24 deletions(-) diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs index 33f1556f0..3840fc71f 100644 --- a/helix-term/src/commands.rs +++ b/helix-term/src/commands.rs @@ -2667,42 +2667,25 @@ fn local_search(cx: &mut Context) { struct LocalSearchConfig { smart_case: bool, file_picker_config: helix_view::editor::FilePickerConfig, - directory_style: Style, number_style: Style, - colon_style: Style, } let editor_config = cx.editor.config(); let config = LocalSearchConfig { smart_case: editor_config.search.smart_case, file_picker_config: editor_config.file_picker.clone(), - directory_style: cx.editor.theme.get("ui.text.directory"), number_style: cx.editor.theme.get("constant.numeric.integer"), - colon_style: cx.editor.theme.get("punctuation"), }; let columns = [ PickerColumn::new("path", |item: &FileResult, config: &LocalSearchConfig| { - let path = helix_stdx::path::get_relative_path(&item.path); - - let directories = path - .parent() - .filter(|p| !p.as_os_str().is_empty()) - .map(|p| format!("{}{}", p.display(), std::path::MAIN_SEPARATOR)) - .unwrap_or_default(); - - let filename = item - .path - .file_name() - .expect("local search paths are normalized (can't end in `..`)") - .to_string_lossy(); - - Cell::from(Spans::from(vec![ - Span::styled(directories, config.directory_style), - Span::raw(filename), - Span::styled(":", config.colon_style), - Span::styled((item.line_num + 1).to_string(), config.number_style), - ])) + Cell::from(Spans::from( + // only show line numbers in the left picker column + vec![Span::styled( + (item.line_num + 1).to_string(), + config.number_style, + )], + )) }), PickerColumn::hidden("contents"), ]; From e37265e16f7b922588b045c571353ee149502ba1 Mon Sep 17 00:00:00 2001 From: oxcrow Date: Sun, 9 Mar 2025 21:30:19 +0530 Subject: [PATCH 03/10] feat: add local_search documentation --- book/src/generated/static-cmd.md | 1 + 1 file changed, 1 insertion(+) diff --git a/book/src/generated/static-cmd.md b/book/src/generated/static-cmd.md index af7515b8e..762403c8c 100644 --- a/book/src/generated/static-cmd.md +++ b/book/src/generated/static-cmd.md @@ -80,6 +80,7 @@ | `search_selection_detect_word_boundaries` | Use current selection as the search pattern, automatically wrapping with `\b` on word boundaries | normal: `` * ``, select: `` * `` | | `make_search_word_bounded` | Modify current search to make it word bounded | | | `global_search` | Global search in workspace folder | normal: `` / ``, select: `` / `` | +| `local_search` | Local search in buffer | normal: `` l ``, select: `` l `` | | `extend_line` | Select current line, if already selected, extend to another line based on the anchor | | | `extend_line_below` | Select current line, if already selected, extend to next line | normal: `` x ``, select: `` x `` | | `extend_line_above` | Select current line, if already selected, extend to previous line | | From fc7955094d391fcefc9deecf1073ff9b822f6283 Mon Sep 17 00:00:00 2001 From: oxcrow Date: Thu, 13 Mar 2025 23:27:02 +0530 Subject: [PATCH 04/10] feat: add placeholder line content in local_search result Display placeholder line content with local_search result. Since columns expect a fn and not a closure it proved challenging to extract data from cx.editor without borrowing cx within the scope. For now a placeholder line content is placed until we fix this. --- helix-term/src/commands.rs | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs index 3840fc71f..d1c8fdee8 100644 --- a/helix-term/src/commands.rs +++ b/helix-term/src/commands.rs @@ -2679,13 +2679,21 @@ fn local_search(cx: &mut Context) { let columns = [ PickerColumn::new("path", |item: &FileResult, config: &LocalSearchConfig| { - Cell::from(Spans::from( - // only show line numbers in the left picker column - vec![Span::styled( - (item.line_num + 1).to_string(), - config.number_style, - )], - )) + let line_num = (item.line_num + 1).to_string(); + // files can never contain more than 999_999_999_999 lines + // thus using maximum line length to be 12 for this formatter is valid + let max_line_num_length = 12; + // whitespace padding to align results after the line number + let padding_length = max_line_num_length - line_num.len(); + let padding = " ".repeat(padding_length); + // extract line content from the editor + let line_content = ""; + // create column value to be displayed in the picker + Cell::from(Spans::from(vec![ + Span::styled(line_num, config.number_style), + Span::raw(padding), + Span::raw(line_content), + ])) }), PickerColumn::hidden("contents"), ]; From b21e6748d15c0f3978f9403d59eb7ad8934e64a0 Mon Sep 17 00:00:00 2001 From: oxcrow Date: Fri, 14 Mar 2025 01:24:02 +0530 Subject: [PATCH 05/10] feat: add line content in local_search result Store and display line content in local_search result. TODO: Fix the awful formatting of the displayed line content. --- helix-term/src/commands.rs | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs index d1c8fdee8..eea565b89 100644 --- a/helix-term/src/commands.rs +++ b/helix-term/src/commands.rs @@ -2653,13 +2653,15 @@ fn local_search(cx: &mut Context) { struct FileResult { path: PathBuf, line_num: usize, + line_content: String, } impl FileResult { - fn new(path: &Path, line_num: usize) -> Self { + fn new(path: &Path, line_num: usize, line_content: String) -> Self { Self { path: path.to_path_buf(), line_num, + line_content, } } } @@ -2686,8 +2688,8 @@ fn local_search(cx: &mut Context) { // whitespace padding to align results after the line number let padding_length = max_line_num_length - line_num.len(); let padding = " ".repeat(padding_length); - // extract line content from the editor - let line_content = ""; + // extract line content to be displayed in the picker + let line_content = item.line_content.clone(); // create column value to be displayed in the picker Cell::from(Spans::from(vec![ Span::styled(line_num, config.number_style), @@ -2775,9 +2777,13 @@ fn local_search(cx: &mut Context) { }; let mut stop = false; - let sink = sinks::UTF8(|line_num, _line_content| { + let sink = sinks::UTF8(|line_num, line_content| { stop = injector - .push(FileResult::new(entry.path(), line_num as usize - 1)) + .push(FileResult::new( + entry.path(), + line_num as usize - 1, + line_content.to_string(), + )) .is_err(); Ok(!stop) From e2768a8b44b57deb6efec1dbcbbc34c53ea12a0f Mon Sep 17 00:00:00 2001 From: oxcrow Date: Fri, 14 Mar 2025 01:52:36 +0530 Subject: [PATCH 06/10] fix: format line content in local_search result Using a maximum limit of 80 characters per line allows the results to be displayed correctly on a wide monitor. Unfortunately on small monitors the issue still persists. Reduce the padding length from 12 to 8. --- helix-term/src/commands.rs | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs index eea565b89..8d1bb8fee 100644 --- a/helix-term/src/commands.rs +++ b/helix-term/src/commands.rs @@ -2682,9 +2682,9 @@ fn local_search(cx: &mut Context) { let columns = [ PickerColumn::new("path", |item: &FileResult, config: &LocalSearchConfig| { let line_num = (item.line_num + 1).to_string(); - // files can never contain more than 999_999_999_999 lines - // thus using maximum line length to be 12 for this formatter is valid - let max_line_num_length = 12; + // files can never contain more than 99_999_999 lines + // thus using maximum line length to be 8 for this formatter is valid + let max_line_num_length = 8; // whitespace padding to align results after the line number let padding_length = max_line_num_length - line_num.len(); let padding = " ".repeat(padding_length); @@ -2777,12 +2777,22 @@ fn local_search(cx: &mut Context) { }; let mut stop = false; + + // Maximum line length of the content displayed within the result picker. + // User should be allowed to control this to accomodate their monitor width. + // TODO: Expose this setting to the user so they can control it. + let local_search_result_line_length = 80; + let sink = sinks::UTF8(|line_num, line_content| { stop = injector .push(FileResult::new( entry.path(), line_num as usize - 1, - line_content.to_string(), + line_content[0..std::cmp::min( + local_search_result_line_length, + line_content.len(), + )] + .to_string(), )) .is_err(); From 8fba25bb86e4294daa19e4ea1c12dc8c469c63da Mon Sep 17 00:00:00 2001 From: oxcrow Date: Fri, 14 Mar 2025 02:14:55 +0530 Subject: [PATCH 07/10] fix: separate line content in local_search result Separate the line number and the line content rendering logic. Use "line" as column header instead of "path". --- helix-term/src/commands.rs | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs index 8d1bb8fee..96a0ac7bb 100644 --- a/helix-term/src/commands.rs +++ b/helix-term/src/commands.rs @@ -2680,7 +2680,7 @@ fn local_search(cx: &mut Context) { }; let columns = [ - PickerColumn::new("path", |item: &FileResult, config: &LocalSearchConfig| { + PickerColumn::new("line", |item: &FileResult, config: &LocalSearchConfig| { let line_num = (item.line_num + 1).to_string(); // files can never contain more than 99_999_999 lines // thus using maximum line length to be 8 for this formatter is valid @@ -2688,16 +2688,18 @@ fn local_search(cx: &mut Context) { // whitespace padding to align results after the line number let padding_length = max_line_num_length - line_num.len(); let padding = " ".repeat(padding_length); - // extract line content to be displayed in the picker - let line_content = item.line_content.clone(); // create column value to be displayed in the picker Cell::from(Spans::from(vec![ Span::styled(line_num, config.number_style), Span::raw(padding), - Span::raw(line_content), ])) }), - PickerColumn::hidden("contents"), + PickerColumn::new("", |item: &FileResult, _config: &LocalSearchConfig| { + // extract line content to be displayed in the picker + let line_content = item.line_content.clone(); + // create column value to be displayed in the picker + Cell::from(Spans::from(vec![Span::raw(line_content)])) + }), ]; let get_files = |query: &str, From 42a70b2f353e8ef58bda8a1de08144099e0722d6 Mon Sep 17 00:00:00 2001 From: oxcrow Date: Mon, 17 Mar 2025 04:42:56 +0530 Subject: [PATCH 08/10] fix: only search through the current document buffer This fixes a bug where results were being returned from all document buffers opened in the editor. Now only one document is searched. The current document. --- helix-term/src/commands.rs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs index 96a0ac7bb..6abe5c97f 100644 --- a/helix-term/src/commands.rs +++ b/helix-term/src/commands.rs @@ -2716,10 +2716,9 @@ fn local_search(cx: &mut Context) { .boxed(); } - let documents: Vec<_> = editor - .documents() - .map(|doc| (doc.path().cloned(), doc.text().to_owned())) - .collect(); + // Only read the current document (not other documents opened in the buffer) + let doc = doc!(editor); + let documents = vec![(doc.path().cloned(), doc.text().to_owned())]; let matcher = match RegexMatcherBuilder::new() .case_smart(config.smart_case) From ccbaadda458cf4734c881cc2460df3b7bb6a4b4f Mon Sep 17 00:00:00 2001 From: Ivan Date: Thu, 3 Apr 2025 18:23:20 +0200 Subject: [PATCH 09/10] - Renamed local_search to local_search_grep for clarity - Added fuzzy searching to local buffer search --- book/src/generated/static-cmd.md | 3 +- helix-term/src/commands.rs | 155 ++++++++++++++++++++++++++++++- helix-term/src/keymap/default.rs | 3 +- 3 files changed, 156 insertions(+), 5 deletions(-) diff --git a/book/src/generated/static-cmd.md b/book/src/generated/static-cmd.md index 762403c8c..5d07174cc 100644 --- a/book/src/generated/static-cmd.md +++ b/book/src/generated/static-cmd.md @@ -80,7 +80,8 @@ | `search_selection_detect_word_boundaries` | Use current selection as the search pattern, automatically wrapping with `\b` on word boundaries | normal: `` * ``, select: `` * `` | | `make_search_word_bounded` | Modify current search to make it word bounded | | | `global_search` | Global search in workspace folder | normal: `` / ``, select: `` / `` | -| `local_search` | Local search in buffer | normal: `` l ``, select: `` l `` | +| `local_search_grep` | Local search in buffer | normal: `` l ``, select: `` l `` | +| `local_search_fuzzy` | Fuzzy local search in buffer | normal: `` L ``, select: `` L `` | | `extend_line` | Select current line, if already selected, extend to another line based on the anchor | | | `extend_line_below` | Select current line, if already selected, extend to next line | normal: `` x ``, select: `` x `` | | `extend_line_above` | Select current line, if already selected, extend to previous line | | diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs index 6abe5c97f..6c284b240 100644 --- a/helix-term/src/commands.rs +++ b/helix-term/src/commands.rs @@ -379,7 +379,8 @@ impl MappableCommand { search_selection_detect_word_boundaries, "Use current selection as the search pattern, automatically wrapping with `\\b` on word boundaries", make_search_word_bounded, "Modify current search to make it word bounded", global_search, "Global search in workspace folder", - local_search, "Local search in buffer", + local_search_grep, "Local search in buffer", + local_search_fuzzy, "Fuzzy local search in buffer", extend_line, "Select current line, if already selected, extend to another line based on the anchor", extend_line_below, "Select current line, if already selected, extend to next line", extend_line_above, "Select current line, if already selected, extend to previous line", @@ -2647,8 +2648,8 @@ fn global_search(cx: &mut Context) { cx.push_layer(Box::new(overlaid(picker))); } -/// Local search in buffer -fn local_search(cx: &mut Context) { +/// Local grep search in buffer +fn local_search_grep(cx: &mut Context) { #[derive(Debug)] struct FileResult { path: PathBuf, @@ -2889,6 +2890,154 @@ fn local_search(cx: &mut Context) { cx.push_layer(Box::new(overlaid(picker))); } +fn local_search_fuzzy(cx: &mut Context) { + #[derive(Debug)] + struct FileResult { + path: std::sync::Arc, + line_num: usize, + file_contents_byte_start: usize, + file_contents_byte_end: usize, + } + + struct LocalSearchData { + file_contents: String, + number_style: Style, + } + + let current_document = doc!(cx.editor); + let Some(current_document_path) = current_document.path() else { + cx.editor.set_error("Failed to get current document path"); + return; + }; + + let file_contents = std::fs::read_to_string(current_document_path).unwrap(); + + let current_document_path = std::sync::Arc::new(current_document_path.clone()); + + let file_results: Vec = file_contents + .lines() + .enumerate() + .filter_map(|(line_num, line)| { + if !line.trim().is_empty() { + // SAFETY: The offsets will be used to index back into the original `file_contents` String + // as a byte slice. Since the `file_contents` will be moved into the `Picker` as part of + // `editor_data`, we know that the `Picker` will take ownership of the underlying String, + // so it will be valid for displaying the `Span` as long as the user uses the `Picker` + // (the `Picker` gets dropped only when a new `Picker` is created). Furthermore, the + // process of reconstructing a `&str` back requires that we have access to the original + // `String` anyways so we can index into it, as is the case when we construct the `Span` + // when creating the `PickerColumn`s, so we know that we are returning the correct + // substring from the original `file_contents`. + // In fact, since we only store offsets, and accessing them from safe rust, there is + // no risk of memory safety (like our &str not living long enough). The only real + // bug would be moving out the original underlying `String` (which we obviously + // don't do). This would lead to an out of bounds crash in the `PickerColumn` function + // call, or a crash when we recreate back the &str if the new underlying `String` + // makes it so that our byte offsets index into the middle of a Unicode grapheme cluster. + // Last but not least, it could make it so that we do display the lines correctly, + // but these are from a different underlying `String` than the original, which would be + // different from the lines in the current buffer. + let beg = + unsafe { line.as_ptr().byte_offset_from(file_contents.as_ptr()) } as usize; + let end = beg + line.len(); + let result = FileResult { + path: current_document_path.clone(), + line_num, + file_contents_byte_start: beg, + file_contents_byte_end: end, + }; + Some(result) + } else { + None + } + }) + .collect(); + + let config = LocalSearchData { + number_style: cx.editor.theme.get("constant.numeric.integer"), + file_contents, + }; + + let columns = [ + PickerColumn::new("line", |item: &FileResult, config: &LocalSearchData| { + let line_num = (item.line_num + 1).to_string(); + // files can never contain more than 99_999_999 lines + // thus using maximum line length to be 8 for this formatter is valid + let max_line_num_length = 8; + // whitespace padding to align results after the line number + let padding_length = max_line_num_length - line_num.len(); + let padding = " ".repeat(padding_length); + // create column value to be displayed in the picker + Cell::from(Spans::from(vec![ + Span::styled(line_num, config.number_style), + Span::raw(padding), + ])) + }), + PickerColumn::new("", |item: &FileResult, config: &LocalSearchData| { + // extract line content to be displayed in the picker + let slice = &config.file_contents.as_bytes() + [item.file_contents_byte_start..item.file_contents_byte_end]; + let content = std::str::from_utf8(slice).unwrap(); + // create column value to be displayed in the picker + Cell::from(Spans::from(vec![Span::raw(content)])) + }), + ]; + + let reg = cx.register.unwrap_or('/'); + cx.editor.registers.last_search_register = reg; + + let picker = Picker::new( + columns, + 1, // contents + [], + config, + move |cx, FileResult { path, line_num, .. }, action| { + let doc = match cx.editor.open(path, action) { + Ok(id) => doc_mut!(cx.editor, &id), + Err(e) => { + cx.editor + .set_error(format!("Failed to open file '{}': {}", path.display(), e)); + return; + } + }; + + let line_num = *line_num; + let view = view_mut!(cx.editor); + let text = doc.text(); + if line_num >= text.len_lines() { + cx.editor.set_error( + "The line you jumped to does not exist anymore because the file has changed.", + ); + return; + } + let start = text.line_to_char(line_num); + let end = text.line_to_char((line_num + 1).min(text.len_lines())); + + doc.set_selection(view.id, Selection::single(start, end)); + if action.align_view(view, doc.id()) { + align_view(doc, view, Align::Center); + } + }, + ) + .with_preview(|_editor, FileResult { path, line_num, .. }| { + Some((path.as_path().into(), Some((*line_num, *line_num)))) + }) + .with_history_register(Some(reg)); + + let injector = picker.injector(); + let timeout = std::time::Instant::now() + std::time::Duration::from_millis(30); + for file_result in file_results { + if injector.push(file_result).is_err() { + break; + } + if std::time::Instant::now() >= timeout { + break; + } + } + + cx.push_layer(Box::new(overlaid(picker))); +} + enum Extend { Above, Below, diff --git a/helix-term/src/keymap/default.rs b/helix-term/src/keymap/default.rs index 1352ecd0c..178ca8a23 100644 --- a/helix-term/src/keymap/default.rs +++ b/helix-term/src/keymap/default.rs @@ -282,7 +282,8 @@ pub fn default() -> HashMap { "P" => paste_clipboard_before, "R" => replace_selections_with_clipboard, "/" => global_search, - "l" => local_search, + "l" => local_search_grep, + "L" => local_search_fuzzy, "k" => hover, "r" => rename_symbol, "h" => select_references_to_symbol_under_cursor, From 6113359b8fa0637a3a5eff2339c332f75bfcd336 Mon Sep 17 00:00:00 2001 From: Ivan Date: Thu, 3 Apr 2025 18:35:34 +0200 Subject: [PATCH 10/10] Removed unnecesary String clone in `local_search_grep` --- helix-term/src/commands.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs index 6c284b240..c74690fa9 100644 --- a/helix-term/src/commands.rs +++ b/helix-term/src/commands.rs @@ -2697,9 +2697,8 @@ fn local_search_grep(cx: &mut Context) { }), PickerColumn::new("", |item: &FileResult, _config: &LocalSearchConfig| { // extract line content to be displayed in the picker - let line_content = item.line_content.clone(); // create column value to be displayed in the picker - Cell::from(Spans::from(vec![Span::raw(line_content)])) + Cell::from(Spans::from(vec![Span::raw(&item.line_content)])) }), ];