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 | | diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs index 2e15dcdcc..b7e9b5cf4 100644 --- a/helix-term/src/commands.rs +++ b/helix-term/src/commands.rs @@ -378,6 +378,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", @@ -2645,6 +2646,248 @@ 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, + line_content: String, + } + + impl FileResult { + fn new(path: &Path, line_num: usize, line_content: String) -> Self { + Self { + path: path.to_path_buf(), + line_num, + line_content, + } + } + } + + struct LocalSearchConfig { + smart_case: bool, + file_picker_config: helix_view::editor::FilePickerConfig, + number_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(), + number_style: cx.editor.theme.get("constant.numeric.integer"), + }; + + let columns = [ + 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 + 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: &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, + 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(); + } + + // 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) + .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; + + // 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[0..std::cmp::min( + local_search_result_line_length, + line_content.len(), + )] + .to_string(), + )) + .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,